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