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}