Skip to main content

zeroclaw_runtime/onboard/ui/
quick.rs

1//! Headless `OnboardUi` backend for `--quick` (scripted / CI) runs.
2//!
3//! Prompt text is the lookup key into `answers`. Unanswered prompts fall back
4//! to the caller-supplied `current`/`default`; when neither is available the
5//! call errors so a malformed script fails loudly instead of hanging or
6//! silently picking a wrong option. `Answer::Back` is never returned — quick
7//! mode has no interactive user to rewind.
8
9use std::collections::HashMap;
10
11use anyhow::{Result, bail};
12use async_trait::async_trait;
13use zeroclaw_config::traits::{Answer, OnboardUi, SelectItem};
14
15#[derive(Debug, Default)]
16pub struct QuickUi {
17    answers: HashMap<String, String>,
18    /// Prompts that fire more than once per run (e.g. a "Channel" select
19    /// hit once to enter a channel and again to pick "Done") need distinct
20    /// answers per call. Sequence entries are consumed in order; if the
21    /// cursor runs off the end, lookup falls back to `answers`.
22    sequences: HashMap<String, Vec<String>>,
23    sequence_cursor: HashMap<String, usize>,
24}
25
26impl QuickUi {
27    pub fn new() -> Self {
28        Self::default()
29    }
30
31    pub fn with(mut self, prompt: impl Into<String>, value: impl Into<String>) -> Self {
32        self.answers.insert(prompt.into(), value.into());
33        self
34    }
35
36    /// Register a sequence of answers for a prompt that fires multiple
37    /// times. The first hit returns `values[0]`, second returns `values[1]`,
38    /// etc. After the sequence is exhausted, subsequent hits fall through
39    /// to `answers` / the prompt's own default.
40    pub fn with_sequence<I, S>(mut self, prompt: impl Into<String>, values: I) -> Self
41    where
42        I: IntoIterator<Item = S>,
43        S: Into<String>,
44    {
45        self.sequences
46            .insert(prompt.into(), values.into_iter().map(Into::into).collect());
47        self
48    }
49
50    /// Look up the next answer for `prompt`: sequence cursor first (and
51    /// advance it), then the single-answer map.
52    fn lookup(&mut self, prompt: &str) -> Option<String> {
53        if let Some(seq) = self.sequences.get(prompt) {
54            let cursor = self.sequence_cursor.entry(prompt.to_string()).or_insert(0);
55            if let Some(v) = seq.get(*cursor) {
56                *cursor += 1;
57                return Some(v.clone());
58            }
59        }
60        self.answers.get(prompt).cloned()
61    }
62}
63
64#[async_trait]
65impl OnboardUi for QuickUi {
66    async fn confirm(&mut self, prompt: &str, default: bool) -> Result<Answer<bool>> {
67        Ok(Answer::Value(match self.lookup(prompt) {
68            Some(value) => matches!(
69                value.trim().to_ascii_lowercase().as_str(),
70                "true" | "yes" | "y" | "1"
71            ),
72            None => default,
73        }))
74    }
75
76    async fn string(
77        &mut self,
78        prompt: &str,
79        current: Option<&str>,
80        placeholder: Option<&str>,
81    ) -> Result<Answer<String>> {
82        if let Some(answer) = self.lookup(prompt) {
83            return Ok(Answer::Value(answer));
84        }
85        if let Some(value) = current {
86            return Ok(Answer::Value(value.to_string()));
87        }
88        // Quick mode is non-interactive — accept a schema/runtime
89        // default the same way the TUI's Enter-on-empty path does.
90        if let Some(value) = placeholder {
91            return Ok(Answer::Value(value.to_string()));
92        }
93        bail!("quick mode: no answer or default provided for prompt {prompt:?}");
94    }
95
96    async fn secret(&mut self, prompt: &str, has_current: bool) -> Result<Answer<Option<String>>> {
97        match (self.lookup(prompt), has_current) {
98            (Some(value), _) => Ok(Answer::Value(Some(value))),
99            (None, true) => Ok(Answer::Value(None)),
100            (None, false) => {
101                bail!("quick mode: secret {prompt:?} is required but no value was supplied")
102            }
103        }
104    }
105
106    async fn select(
107        &mut self,
108        prompt: &str,
109        items: &[SelectItem],
110        current: Option<usize>,
111    ) -> Result<Answer<usize>> {
112        if let Some(answer) = self.lookup(prompt) {
113            if let Some(index) = items
114                .iter()
115                .position(|item| item.label.eq_ignore_ascii_case(&answer))
116            {
117                return Ok(Answer::Value(index));
118            }
119            bail!("quick mode: {prompt:?} answer {answer:?} matches none of the available options");
120        }
121        if let Some(index) = current {
122            return Ok(Answer::Value(index));
123        }
124        bail!("quick mode: no answer or default provided for prompt {prompt:?}");
125    }
126
127    async fn editor(&mut self, _hint: &str, initial: &str) -> Result<Answer<String>> {
128        Ok(Answer::Value(initial.to_string()))
129    }
130
131    fn heading(&mut self, level: u8, text: &str) {
132        let marker = "#".repeat(level.clamp(1, 6) as usize);
133        println!("\n{marker} {text}");
134    }
135
136    fn note(&mut self, msg: &str) {
137        println!("\n{msg}\n");
138    }
139
140    fn status(&mut self, msg: &str) {
141        println!("  {msg}");
142    }
143
144    fn warn(&mut self, msg: &str) {
145        eprintln!("⚠️  {msg}");
146    }
147}