Skip to main content

greentic_flow/
questions.rs

1use anyhow::{Context, Result, anyhow};
2use serde_json::Value;
3use std::collections::HashMap;
4use std::io::{self, Read, Write};
5
6#[derive(Debug, Clone, PartialEq, Eq)]
7pub enum QuestionKind {
8    String,
9    Bool,
10    Choice,
11    Int,
12}
13
14#[derive(Debug, Clone)]
15pub struct Question {
16    pub id: String,
17    pub prompt: String,
18    pub kind: QuestionKind,
19    pub required: bool,
20    pub default: Option<Value>,
21    pub choices: Vec<Value>,
22    pub show_if: Option<Value>,
23    pub writes_to: Option<String>,
24}
25
26pub type Answers = HashMap<String, Value>;
27
28#[derive(Debug, Clone)]
29pub struct MissingRequired {
30    pub missing: Vec<String>,
31    pub template: String,
32}
33
34impl std::fmt::Display for MissingRequired {
35    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
36        write!(
37            f,
38            "missing required answers: {}. Provide via --answers/--answers-file. Example:\n{}",
39            self.missing.join(", "),
40            self.template
41        )
42    }
43}
44
45impl std::error::Error for MissingRequired {}
46
47pub fn merge_answers(cli_answers: Option<Answers>, file_answers: Option<Answers>) -> Answers {
48    let mut merged = Answers::new();
49    if let Some(cli) = cli_answers {
50        merged.extend(cli);
51    }
52    if let Some(file) = file_answers {
53        merged.extend(file);
54    }
55    merged
56}
57
58pub fn validate_required(questions: &[Question], answers: &Answers) -> Result<()> {
59    let missing = missing_required(questions, answers);
60    if missing.is_empty() {
61        return Ok(());
62    }
63    let template = serde_json::to_string_pretty(&template_for_questions(questions, answers))
64        .unwrap_or_else(|_| "{}".to_string());
65    Err(MissingRequired { missing, template }.into())
66}
67
68pub fn run_interactive(questions: &[Question]) -> Result<Answers> {
69    run_interactive_with_seed(questions, Answers::new())
70}
71
72pub fn run_interactive_with_seed(questions: &[Question], seed: Answers) -> Result<Answers> {
73    let stdin = io::stdin();
74    let stdout = io::stdout();
75    run_interactive_with_io(questions, seed, stdin.lock(), stdout.lock())
76}
77
78pub fn run_interactive_with_io<R: Read, W: Write>(
79    questions: &[Question],
80    mut answers: Answers,
81    mut reader: R,
82    mut writer: W,
83) -> Result<Answers> {
84    let mut input = String::new();
85    for question in questions {
86        if !question_visible(question, &answers) {
87            continue;
88        }
89        if answers.contains_key(&question.id) {
90            continue;
91        }
92        let effective_default = question.default.clone();
93        loop {
94            input.clear();
95            write_prompt(&mut writer, question, effective_default.as_ref())?;
96            writer.flush().ok();
97            let read_any = read_line(&mut reader, &mut input)?;
98            let raw = input.trim();
99            if raw.is_empty() {
100                if let Some(default) = effective_default.clone() {
101                    answers.insert(question.id.clone(), default);
102                    break;
103                }
104                if !read_any {
105                    return Err(anyhow!(
106                        "stdin closed while waiting for answer for '{}'",
107                        question.id
108                    ));
109                }
110                if question.required {
111                    continue;
112                }
113                break;
114            }
115            match parse_answer(raw, question) {
116                Ok(value) => {
117                    answers.insert(question.id.clone(), value);
118                    break;
119                }
120                Err(_) => {
121                    continue;
122                }
123            }
124        }
125    }
126    Ok(answers)
127}
128
129pub fn extract_questions_from_flow(flow: &Value) -> Result<Vec<Question>> {
130    let Some(nodes) = flow.get("nodes").and_then(Value::as_object) else {
131        return Ok(Vec::new());
132    };
133    let mut questions = Vec::new();
134    for node in nodes.values() {
135        let Some(qnode) = node.get("questions") else {
136            continue;
137        };
138        let fields = qnode
139            .get("fields")
140            .and_then(Value::as_array)
141            .ok_or_else(|| anyhow!("questions node missing fields array"))?;
142        for field in fields {
143            let id = field
144                .get("id")
145                .and_then(Value::as_str)
146                .ok_or_else(|| anyhow!("questions field missing id"))?;
147            let prompt = field
148                .get("prompt")
149                .and_then(Value::as_str)
150                .unwrap_or(id)
151                .to_string();
152            let default = field.get("default").cloned();
153            let required = field
154                .get("required")
155                .and_then(Value::as_bool)
156                .unwrap_or(default.is_none());
157            let kind = match field.get("type").and_then(Value::as_str) {
158                Some("bool") | Some("boolean") => QuestionKind::Bool,
159                Some("int") | Some("integer") => QuestionKind::Int,
160                Some("choice") | Some("enum") => QuestionKind::Choice,
161                _ => QuestionKind::String,
162            };
163            let choices = field
164                .get("options")
165                .and_then(Value::as_array)
166                .map(|opts| opts.to_vec())
167                .unwrap_or_default();
168            let show_if = field.get("show_if").cloned();
169            questions.push(Question {
170                id: id.to_string(),
171                prompt,
172                kind,
173                required,
174                default,
175                choices,
176                show_if,
177                writes_to: field
178                    .get("writes_to")
179                    .and_then(Value::as_str)
180                    .map(|s| s.to_string()),
181            });
182        }
183    }
184    Ok(questions)
185}
186fn write_prompt<W: Write>(
187    writer: &mut W,
188    question: &Question,
189    default_override: Option<&Value>,
190) -> Result<()> {
191    write!(writer, "Question ({}): {}", question.id, question.prompt).context("write prompt")?;
192    if let Some(default) = default_override.or(question.default.as_ref()) {
193        write!(writer, " [default: {}]", display_value(default)).ok();
194    }
195    writeln!(writer).ok();
196    if question.kind == QuestionKind::Choice && !question.choices.is_empty() {
197        for (idx, choice) in question.choices.iter().enumerate() {
198            writeln!(writer, "  {}) {}", idx + 1, display_value(choice)).ok();
199        }
200    }
201    Ok(())
202}
203
204fn read_line<R: Read>(reader: &mut R, buf: &mut String) -> Result<bool> {
205    let mut bytes = Vec::new();
206    let mut byte = [0u8; 1];
207    let mut read_any = false;
208    while reader.read(&mut byte).context("read input")? == 1 {
209        read_any = true;
210        if byte[0] == b'\n' {
211            break;
212        }
213        bytes.push(byte[0]);
214    }
215    *buf = String::from_utf8(bytes).context("parse input as UTF-8")?;
216    Ok(read_any)
217}
218
219fn parse_answer(raw: &str, question: &Question) -> Result<Value> {
220    match question.kind {
221        QuestionKind::String => Ok(Value::String(raw.to_string())),
222        QuestionKind::Bool => parse_bool(raw).map(Value::Bool),
223        QuestionKind::Int => {
224            let parsed = raw.parse::<i64>().map_err(|_| anyhow!("invalid integer"))?;
225            Ok(Value::Number(parsed.into()))
226        }
227        QuestionKind::Choice => parse_choice(raw, question),
228    }
229}
230
231fn parse_bool(raw: &str) -> Result<bool> {
232    let lowered = raw.trim().to_lowercase();
233    let compact: String = lowered.chars().filter(|c| !c.is_whitespace()).collect();
234    match compact.as_str() {
235        "yes=true" => Ok(true),
236        "no=false" => Ok(false),
237        "y" | "yes" | "true" | "1" => Ok(true),
238        "n" | "no" | "false" | "0" => Ok(false),
239        _ => Err(anyhow!("invalid boolean")),
240    }
241}
242
243fn parse_choice(raw: &str, question: &Question) -> Result<Value> {
244    if let Ok(idx) = raw.parse::<usize>()
245        && idx >= 1
246        && idx <= question.choices.len()
247    {
248        return Ok(question.choices[idx - 1].clone());
249    }
250    for choice in &question.choices {
251        if display_value(choice) == raw {
252            return Ok(choice.clone());
253        }
254    }
255    Err(anyhow!("invalid choice"))
256}
257
258fn display_value(value: &Value) -> String {
259    match value {
260        Value::String(s) => s.clone(),
261        other => other.to_string(),
262    }
263}
264
265pub fn apply_writes_to(
266    mut base: Value,
267    questions: &[Question],
268    answers: &Answers,
269) -> Result<Value> {
270    for question in questions {
271        let Some(path) = question.writes_to.as_deref() else {
272            continue;
273        };
274        let Some(answer) = answers.get(&question.id) else {
275            continue;
276        };
277        let tokens = parse_path_tokens(path)?;
278        set_value_at_path(&mut base, &tokens, answer.clone());
279    }
280    Ok(base)
281}
282
283pub fn extract_answers_from_payload(questions: &[Question], payload: &Value) -> Answers {
284    let mut answers = Answers::new();
285    for question in questions {
286        let Some(path) = question.writes_to.as_deref() else {
287            continue;
288        };
289        if let Ok(tokens) = parse_path_tokens(path)
290            && let Some(value) = get_value_at_path(payload, &tokens)
291        {
292            answers.insert(question.id.clone(), value);
293        }
294    }
295    answers
296}
297
298#[derive(Debug, Clone, PartialEq, Eq)]
299enum PathToken {
300    Key(String),
301    Index(usize),
302}
303
304fn parse_path_tokens(path: &str) -> Result<Vec<PathToken>> {
305    let mut tokens = Vec::new();
306    let mut buf = String::new();
307    let mut chars = path.chars().peekable();
308    while let Some(ch) = chars.next() {
309        match ch {
310            '.' => {
311                if !buf.is_empty() {
312                    tokens.push(PathToken::Key(std::mem::take(&mut buf)));
313                }
314            }
315            '[' => {
316                if !buf.is_empty() {
317                    tokens.push(PathToken::Key(std::mem::take(&mut buf)));
318                }
319                let mut idx_buf = String::new();
320                for c in chars.by_ref() {
321                    if c == ']' {
322                        break;
323                    }
324                    idx_buf.push(c);
325                }
326                let idx = idx_buf
327                    .parse::<usize>()
328                    .map_err(|_| anyhow!("invalid index in writes_to path"))?;
329                tokens.push(PathToken::Index(idx));
330            }
331            _ => buf.push(ch),
332        }
333    }
334    if !buf.is_empty() {
335        tokens.push(PathToken::Key(buf));
336    }
337    if tokens.is_empty() {
338        Err(anyhow!("writes_to path is empty"))
339    } else {
340        Ok(tokens)
341    }
342}
343
344fn ensure_array_len(arr: &mut Vec<Value>, index: usize) {
345    if arr.len() <= index {
346        arr.resize(index + 1, Value::Null);
347    }
348}
349
350fn set_value_at_path(target: &mut Value, tokens: &[PathToken], value: Value) {
351    let mut current = target;
352    for (i, token) in tokens.iter().enumerate() {
353        let last = i == tokens.len() - 1;
354        match token {
355            PathToken::Key(key) => {
356                if !current.is_object() {
357                    *current = Value::Object(serde_json::Map::new());
358                }
359                let obj = current.as_object_mut().unwrap();
360                if last {
361                    obj.insert(key.clone(), value);
362                    return;
363                }
364                current = obj.entry(key.clone()).or_insert(Value::Null);
365            }
366            PathToken::Index(index) => {
367                if !current.is_array() {
368                    *current = Value::Array(Vec::new());
369                }
370                let arr = current.as_array_mut().unwrap();
371                ensure_array_len(arr, *index);
372                if last {
373                    arr[*index] = value;
374                    return;
375                }
376                current = &mut arr[*index];
377            }
378        }
379    }
380}
381
382fn get_value_at_path(target: &Value, tokens: &[PathToken]) -> Option<Value> {
383    let mut current = target;
384    for token in tokens {
385        match token {
386            PathToken::Key(key) => {
387                current = current.as_object()?.get(key)?;
388            }
389            PathToken::Index(index) => {
390                current = current.as_array()?.get(*index)?;
391            }
392        }
393    }
394    Some(current.clone())
395}
396
397fn missing_required(questions: &[Question], answers: &Answers) -> Vec<String> {
398    questions
399        .iter()
400        .filter(|q| q.required && question_visible(q, answers) && !answers.contains_key(&q.id))
401        .map(|q| q.id.clone())
402        .collect::<Vec<_>>()
403}
404
405fn template_for_questions(questions: &[Question], answers: &Answers) -> Value {
406    let mut obj = serde_json::Map::new();
407    for question in questions {
408        if !question_visible(question, answers) {
409            continue;
410        }
411        let value = if let Some(default) = question.default.clone() {
412            default
413        } else {
414            match question.kind {
415                QuestionKind::Bool => Value::Bool(false),
416                QuestionKind::Int => Value::Number(0.into()),
417                QuestionKind::Choice => question
418                    .choices
419                    .first()
420                    .cloned()
421                    .unwrap_or_else(|| Value::String(String::new())),
422                QuestionKind::String => Value::String(String::new()),
423            }
424        };
425        obj.insert(question.id.clone(), value);
426    }
427    Value::Object(obj)
428}
429
430fn question_visible(question: &Question, answers: &Answers) -> bool {
431    let Some(show_if) = &question.show_if else {
432        return true;
433    };
434    match show_if {
435        Value::Bool(value) => *value,
436        Value::Object(map) => {
437            let Some(id) = map.get("id").and_then(Value::as_str) else {
438                return true;
439            };
440            let Some(expected) = map.get("equals") else {
441                return true;
442            };
443            let Some(actual) = answers.get(id) else {
444                return false;
445            };
446            actual == expected
447        }
448        _ => true,
449    }
450}
451
452#[cfg(test)]
453mod tests {
454    use super::*;
455    use serde_json::json;
456    use std::io::Cursor;
457
458    #[test]
459    fn interactive_accepts_default_on_empty() {
460        let question = Question {
461            id: "name".to_string(),
462            prompt: "Name?".to_string(),
463            kind: QuestionKind::String,
464            required: true,
465            default: Some(Value::String("Ada".to_string())),
466            choices: Vec::new(),
467            show_if: None,
468            writes_to: None,
469        };
470        let input = Cursor::new("\n");
471        let output = Vec::new();
472        let answers = run_interactive_with_io(&[question], Answers::new(), input, output).unwrap();
473        assert_eq!(answers.get("name"), Some(&Value::String("Ada".to_string())));
474    }
475
476    #[test]
477    fn choice_accepts_index_or_value() {
478        let question = Question {
479            id: "color".to_string(),
480            prompt: "Color?".to_string(),
481            kind: QuestionKind::Choice,
482            required: true,
483            default: None,
484            choices: vec![
485                Value::String("red".to_string()),
486                Value::String("blue".to_string()),
487            ],
488            show_if: None,
489            writes_to: None,
490        };
491        let input = Cursor::new("2\n");
492        let output = Vec::new();
493        let answers = run_interactive_with_io(
494            std::slice::from_ref(&question),
495            Answers::new(),
496            input,
497            output,
498        )
499        .unwrap();
500        assert_eq!(
501            answers.get("color"),
502            Some(&Value::String("blue".to_string()))
503        );
504
505        let input = Cursor::new("red\n");
506        let output = Vec::new();
507        let answers = run_interactive_with_io(&[question], Answers::new(), input, output).unwrap();
508        assert_eq!(
509            answers.get("color"),
510            Some(&Value::String("red".to_string()))
511        );
512    }
513
514    #[test]
515    fn missing_required_reports_all_fields() {
516        let questions = vec![
517            Question {
518                id: "a".to_string(),
519                prompt: "A?".to_string(),
520                kind: QuestionKind::String,
521                required: true,
522                default: None,
523                choices: Vec::new(),
524                show_if: None,
525                writes_to: None,
526            },
527            Question {
528                id: "b".to_string(),
529                prompt: "B?".to_string(),
530                kind: QuestionKind::String,
531                required: true,
532                default: None,
533                choices: Vec::new(),
534                show_if: None,
535                writes_to: None,
536            },
537        ];
538        let err = validate_required(&questions, &Answers::new()).unwrap_err();
539        let msg = err.to_string();
540        assert!(msg.contains("a"));
541        assert!(msg.contains("b"));
542        assert!(msg.contains("--answers"));
543        assert!(msg.contains('{'));
544    }
545
546    #[test]
547    fn interactive_parses_int_and_bool() {
548        let questions = vec![
549            Question {
550                id: "count".to_string(),
551                prompt: "Count?".to_string(),
552                kind: QuestionKind::Int,
553                required: true,
554                default: None,
555                choices: Vec::new(),
556                show_if: None,
557                writes_to: None,
558            },
559            Question {
560                id: "flag".to_string(),
561                prompt: "Flag?".to_string(),
562                kind: QuestionKind::Bool,
563                required: true,
564                default: None,
565                choices: Vec::new(),
566                show_if: None,
567                writes_to: None,
568            },
569        ];
570        let input = Cursor::new("42\ny\n");
571        let output = Vec::new();
572        let answers = run_interactive_with_io(&questions, Answers::new(), input, output).unwrap();
573        assert_eq!(answers.get("count"), Some(&Value::Number(42.into())));
574        assert_eq!(answers.get("flag"), Some(&Value::Bool(true)));
575    }
576
577    #[test]
578    fn interactive_accepts_yes_no_equals_true_false() {
579        let questions = vec![
580            Question {
581                id: "enabled".to_string(),
582                prompt: "Enabled?".to_string(),
583                kind: QuestionKind::Bool,
584                required: true,
585                default: None,
586                choices: Vec::new(),
587                show_if: None,
588                writes_to: None,
589            },
590            Question {
591                id: "disabled".to_string(),
592                prompt: "Disabled?".to_string(),
593                kind: QuestionKind::Bool,
594                required: true,
595                default: None,
596                choices: Vec::new(),
597                show_if: None,
598                writes_to: None,
599            },
600        ];
601        let input = Cursor::new("YeS = TrUe\nNo = False\n");
602        let output = Vec::new();
603        let answers = run_interactive_with_io(&questions, Answers::new(), input, output).unwrap();
604        assert_eq!(answers.get("enabled"), Some(&Value::Bool(true)));
605        assert_eq!(answers.get("disabled"), Some(&Value::Bool(false)));
606    }
607
608    #[test]
609    fn interactive_respects_show_if_equals() {
610        let questions = vec![
611            Question {
612                id: "mode".to_string(),
613                prompt: "Mode?".to_string(),
614                kind: QuestionKind::String,
615                required: true,
616                default: Some(Value::String("asset".to_string())),
617                choices: Vec::new(),
618                show_if: None,
619                writes_to: None,
620            },
621            Question {
622                id: "asset_path".to_string(),
623                prompt: "Asset?".to_string(),
624                kind: QuestionKind::String,
625                required: true,
626                default: None,
627                choices: Vec::new(),
628                show_if: Some(json!({ "id": "mode", "equals": "asset" })),
629                writes_to: None,
630            },
631        ];
632        let input = Cursor::new("\npath.json\n");
633        let output = Vec::new();
634        let answers = run_interactive_with_io(&questions, Answers::new(), input, output).unwrap();
635        assert_eq!(
636            answers.get("mode"),
637            Some(&Value::String("asset".to_string()))
638        );
639        assert_eq!(
640            answers.get("asset_path"),
641            Some(&Value::String("path.json".to_string()))
642        );
643
644        let input = Cursor::new("inline\n");
645        let output = Vec::new();
646        let answers = run_interactive_with_io(&questions, Answers::new(), input, output).unwrap();
647        assert_eq!(
648            answers.get("mode"),
649            Some(&Value::String("inline".to_string()))
650        );
651        assert!(!answers.contains_key("asset_path"));
652    }
653
654    #[test]
655    fn validate_required_skips_hidden_questions() {
656        let questions = vec![Question {
657            id: "hidden".to_string(),
658            prompt: "Hidden?".to_string(),
659            kind: QuestionKind::String,
660            required: true,
661            default: None,
662            choices: Vec::new(),
663            show_if: Some(Value::Bool(false)),
664            writes_to: None,
665        }];
666        validate_required(&questions, &Answers::new()).unwrap();
667    }
668
669    #[test]
670    fn writes_to_creates_nested_objects() {
671        let questions = vec![Question {
672            id: "asset_path".to_string(),
673            prompt: "Asset?".to_string(),
674            kind: QuestionKind::String,
675            required: true,
676            default: None,
677            choices: Vec::new(),
678            show_if: None,
679            writes_to: Some("card_spec.asset_path".to_string()),
680        }];
681        let mut answers = Answers::new();
682        answers.insert(
683            "asset_path".to_string(),
684            Value::String("path.json".to_string()),
685        );
686        let output = apply_writes_to(Value::Object(Default::default()), &questions, &answers)
687            .expect("apply");
688        let card_spec = output.get("card_spec").and_then(Value::as_object).unwrap();
689        assert_eq!(
690            card_spec.get("asset_path").and_then(Value::as_str),
691            Some("path.json")
692        );
693    }
694
695    #[test]
696    fn writes_to_supports_array_indexes() {
697        let questions = vec![Question {
698            id: "action_id".to_string(),
699            prompt: "Action?".to_string(),
700            kind: QuestionKind::String,
701            required: true,
702            default: None,
703            choices: Vec::new(),
704            show_if: None,
705            writes_to: Some("actions[0].id".to_string()),
706        }];
707        let mut answers = Answers::new();
708        answers.insert(
709            "action_id".to_string(),
710            Value::String("action-1".to_string()),
711        );
712        let output = apply_writes_to(Value::Object(Default::default()), &questions, &answers)
713            .expect("apply");
714        let actions = output.get("actions").and_then(Value::as_array).unwrap();
715        let first = actions[0].as_object().unwrap();
716        assert_eq!(first.get("id").and_then(Value::as_str), Some("action-1"));
717    }
718}