1use serde::{Deserialize, Serialize};
9
10use crate::result::{ExecResult, LatchRequest};
11
12#[non_exhaustive]
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
22#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
23#[serde(rename_all = "lowercase")]
24pub enum EntryType {
25 #[default]
27 Text,
28 File,
30 Directory,
32 Executable,
34 Symlink,
36}
37
38#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
49#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
50#[serde(default)]
51pub struct OutputNode {
52 pub name: String,
54 pub entry_type: EntryType,
56 pub text: Option<String>,
65 pub cells: Vec<String>,
67 pub children: Vec<OutputNode>,
69}
70
71impl OutputNode {
72 pub fn new(name: impl Into<String>) -> Self {
74 Self {
75 name: name.into(),
76 ..Default::default()
77 }
78 }
79
80 pub fn text(content: impl Into<String>) -> Self {
82 Self {
83 text: Some(content.into()),
84 ..Default::default()
85 }
86 }
87
88 pub fn with_entry_type(mut self, entry_type: EntryType) -> Self {
90 self.entry_type = entry_type;
91 self
92 }
93
94 pub fn with_cells(mut self, cells: Vec<String>) -> Self {
96 self.cells = cells;
97 self
98 }
99
100 pub fn with_children(mut self, children: Vec<OutputNode>) -> Self {
102 self.children = children;
103 self
104 }
105
106 pub fn with_text(mut self, text: impl Into<String>) -> Self {
108 self.text = Some(text.into());
109 self
110 }
111
112 pub fn is_text_only(&self) -> bool {
114 self.text.is_some() && self.name.is_empty() && self.cells.is_empty() && self.children.is_empty()
115 }
116
117 pub fn has_children(&self) -> bool {
119 !self.children.is_empty()
120 }
121
122 pub fn estimated_byte_size(&self) -> usize {
124 if self.children.is_empty() {
125 self.name.len() + self.text.as_ref().map_or(0, |t| t.len())
126 } else {
127 let mut size = self.name.len() + 2; for (i, child) in self.children.iter().enumerate() {
130 if i > 0 {
131 size += 1; }
133 size += child.estimated_byte_size();
134 }
135 size + 1 }
137 }
138
139 pub fn write_canonical(&self, w: &mut dyn std::io::Write, budget: usize) -> std::io::Result<usize> {
141 if self.children.is_empty() {
142 w.write_all(self.name.as_bytes())?;
143 return Ok(self.name.len());
144 }
145 let mut written = 0;
146 w.write_all(self.name.as_bytes())?;
147 written += self.name.len();
148 if written >= budget {
149 return Ok(written);
150 }
151 w.write_all(b"/{")?;
152 written += 2;
153 for (i, child) in self.children.iter().enumerate() {
154 if written >= budget {
155 break;
156 }
157 if i > 0 {
158 w.write_all(b",")?;
159 written += 1;
160 }
161 written += child.write_canonical(w, budget.saturating_sub(written))?;
162 }
163 w.write_all(b"}")?;
164 written += 1;
165 Ok(written)
166 }
167
168 pub fn display_name(&self) -> &str {
170 if self.name.is_empty() {
171 self.text.as_deref().unwrap_or("")
172 } else {
173 &self.name
174 }
175 }
176}
177
178#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
196#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
197#[serde(default)]
198#[non_exhaustive]
199pub struct OutputData {
200 pub headers: Option<Vec<String>>,
202 pub root: Vec<OutputNode>,
204 #[serde(default, skip_serializing_if = "Option::is_none")]
221 pub rich_json: Option<serde_json::Value>,
222}
223
224impl OutputData {
225 pub fn new() -> Self {
227 Self::default()
228 }
229
230 pub fn text(content: impl Into<String>) -> Self {
234 Self {
235 headers: None,
236 root: vec![OutputNode::text(content)],
237 rich_json: None,
238 }
239 }
240
241 pub fn nodes(nodes: Vec<OutputNode>) -> Self {
243 Self {
244 headers: None,
245 root: nodes,
246 rich_json: None,
247 }
248 }
249
250 pub fn table(headers: Vec<String>, nodes: Vec<OutputNode>) -> Self {
252 Self {
253 headers: Some(headers),
254 root: nodes,
255 rich_json: None,
256 }
257 }
258
259 pub fn with_headers(mut self, headers: Vec<String>) -> Self {
261 self.headers = Some(headers);
262 self
263 }
264
265 pub fn with_rich_json(mut self, value: serde_json::Value) -> Self {
267 self.rich_json = Some(value);
268 self
269 }
270
271 pub fn is_simple_text(&self) -> bool {
273 self.root.len() == 1 && self.root[0].is_text_only()
274 }
275
276 pub fn is_flat(&self) -> bool {
278 self.root.iter().all(|n| !n.has_children())
279 }
280
281 pub fn is_tabular(&self) -> bool {
283 self.root.iter().any(|n| !n.cells.is_empty())
284 }
285
286 pub fn as_text(&self) -> Option<&str> {
288 if self.is_simple_text() {
289 self.root[0].text.as_deref()
290 } else {
291 None
292 }
293 }
294
295 pub fn into_text(mut self) -> Result<String, Self> {
299 if self.root.len() == 1 && self.root[0].is_text_only() {
300 Ok(self.root.pop().and_then(|n| n.text).unwrap_or_default())
301 } else {
302 Err(self)
303 }
304 }
305
306 pub fn estimated_byte_size(&self) -> usize {
311 if self.root.len() == 1 && self.root[0].is_text_only() {
312 return self.root[0].text.as_ref().map_or(0, |t| t.len());
313 }
314
315 if self.is_flat() {
316 let mut size = 0;
317 for (i, n) in self.root.iter().enumerate() {
318 if i > 0 {
319 size += 1; }
321 size += n.display_name().len();
322 for cell in &n.cells {
323 size += 1 + cell.len(); }
325 }
326 return size;
327 }
328
329 let mut size = 0;
331 for (i, n) in self.root.iter().enumerate() {
332 if i > 0 {
333 size += 1; }
335 size += n.estimated_byte_size();
336 }
337 size
338 }
339
340 pub fn write_canonical(&self, w: &mut dyn std::io::Write, budget: Option<usize>) -> std::io::Result<usize> {
346 let mut written = 0usize;
347 let budget = budget.unwrap_or(usize::MAX);
348
349 if self.root.len() == 1 && self.root[0].is_text_only() {
350 if let Some(ref text) = self.root[0].text {
351 w.write_all(text.as_bytes())?;
352 return Ok(text.len());
353 }
354 return Ok(0);
355 }
356
357 if self.is_flat() {
358 for (i, n) in self.root.iter().enumerate() {
359 if i > 0 {
360 w.write_all(b"\n")?;
361 written += 1;
362 }
363 let name = n.display_name();
364 w.write_all(name.as_bytes())?;
365 written += name.len();
366 for cell in &n.cells {
367 w.write_all(b"\t")?;
368 w.write_all(cell.as_bytes())?;
369 written += 1 + cell.len();
370 }
371 if written > budget {
372 return Ok(written);
373 }
374 }
375 return Ok(written);
376 }
377
378 for (i, n) in self.root.iter().enumerate() {
380 if i > 0 {
381 w.write_all(b"\n")?;
382 written += 1;
383 }
384 written += n.write_canonical(w, budget.saturating_sub(written))?;
385 if written > budget {
386 return Ok(written);
387 }
388 }
389 Ok(written)
390 }
391
392 pub fn to_canonical_string(&self) -> String {
401 if let Some(text) = self.as_text() {
402 return text.to_string();
403 }
404
405 if self.is_flat() {
407 return self.root.iter()
408 .map(|n| {
409 if n.cells.is_empty() {
410 n.display_name().to_string()
411 } else {
412 let mut parts = vec![n.display_name().to_string()];
414 parts.extend(n.cells.iter().cloned());
415 parts.join("\t")
416 }
417 })
418 .collect::<Vec<_>>()
419 .join("\n");
420 }
421
422 fn format_node(node: &OutputNode) -> String {
424 if node.children.is_empty() {
425 node.name.clone()
426 } else {
427 let children: Vec<String> = node.children.iter()
428 .map(format_node)
429 .collect();
430 format!("{}/{{{}}}", node.name, children.join(","))
431 }
432 }
433
434 self.root.iter()
435 .map(format_node)
436 .collect::<Vec<_>>()
437 .join("\n")
438 }
439
440 pub fn to_json(&self) -> serde_json::Value {
451 if let Some(rich) = &self.rich_json {
454 return rich.clone();
455 }
456 if let Some(text) = self.as_text() {
458 return serde_json::Value::String(text.to_string());
459 }
460
461 if let Some(ref headers) = self.headers {
466 fn row_to_json(node: &OutputNode, headers: &[String]) -> serde_json::Value {
467 let mut map = serde_json::Map::new();
468 if let Some(first) = headers.first() {
470 map.insert(first.clone(), serde_json::Value::String(node.name.clone()));
471 }
472 for (header, cell) in headers.iter().skip(1).zip(node.cells.iter()) {
474 map.insert(header.clone(), serde_json::Value::String(cell.clone()));
475 }
476 if !node.children.is_empty() {
477 let children: Vec<serde_json::Value> = node
478 .children
479 .iter()
480 .map(|child| row_to_json(child, headers))
481 .collect();
482 map.insert("children".to_string(), serde_json::Value::Array(children));
483 }
484 serde_json::Value::Object(map)
485 }
486 let rows: Vec<serde_json::Value> = self
487 .root
488 .iter()
489 .map(|node| row_to_json(node, headers))
490 .collect();
491 return serde_json::Value::Array(rows);
492 }
493
494 if !self.is_flat() {
496 fn node_to_json(node: &OutputNode) -> serde_json::Value {
497 if node.children.is_empty() {
498 serde_json::Value::Null
499 } else {
500 let mut map = serde_json::Map::new();
501 for child in &node.children {
502 map.insert(child.name.clone(), node_to_json(child));
503 }
504 serde_json::Value::Object(map)
505 }
506 }
507
508 if self.root.len() == 1 {
510 return node_to_json(&self.root[0]);
511 }
512 let mut map = serde_json::Map::new();
514 for node in &self.root {
515 map.insert(node.name.clone(), node_to_json(node));
516 }
517 return serde_json::Value::Object(map);
518 }
519
520 let items: Vec<serde_json::Value> = self.root.iter()
522 .map(|n| serde_json::Value::String(n.display_name().to_string()))
523 .collect();
524 serde_json::Value::Array(items)
525 }
526}
527
528#[non_exhaustive]
534#[derive(Debug, Clone, Copy, PartialEq, Eq)]
535pub enum OutputFormat {
536 Json,
538}
539
540fn latch_envelope(result: &ExecResult, latch: &LatchRequest) -> serde_json::Value {
552 let error = if !result.err.is_empty() {
553 result.err.clone()
554 } else {
555 result.text_out().into_owned()
556 };
557 let mut obj = serde_json::json!({
558 "error": error,
559 "code": result.code,
560 });
561 if let Some(data) = &result.data {
562 obj["data"] = crate::result::value_to_json(data);
563 }
564 if let Ok(v) = serde_json::to_value(latch) {
566 obj["latch"] = v;
567 }
568 obj
569}
570
571pub fn apply_output_format(mut result: ExecResult, format: OutputFormat) -> ExecResult {
577 if result.is_bytes() {
580 let envelope = crate::bytes::bytes_to_envelope(result.out_bytes().unwrap_or(&[]));
581 match format {
582 OutputFormat::Json => {
583 result.set_out(
584 serde_json::to_string(&envelope).unwrap_or_else(|_| "null".to_string()),
585 );
586 result.data = Some(crate::result::json_to_value(envelope));
587 result.set_output(None);
588 }
589 }
590 return result;
591 }
592 if let Some(latch) = &result.latch {
602 let obj = match format {
603 OutputFormat::Json => latch_envelope(&result, latch),
604 };
605 let out = serde_json::to_string(&obj).unwrap_or_else(|_| "null".to_string());
606 result.data = Some(crate::result::json_to_value(obj));
607 result.set_out(out);
608 result.set_output(None);
609 return result;
610 }
611 if !result.has_output() && result.text_out().is_empty() {
612 if !result.ok() && !result.err.is_empty() {
618 match format {
619 OutputFormat::Json => {
620 let mut obj = serde_json::json!({
621 "error": result.err,
622 "code": result.code,
623 });
624 if let Some(data) = &result.data {
629 obj["data"] = crate::result::value_to_json(data);
630 }
631 let out =
632 serde_json::to_string(&obj).unwrap_or_else(|_| "null".to_string());
633 result.set_out(out);
634 result.data = Some(crate::result::json_to_value(obj));
635 }
636 }
637 }
638 return result;
639 }
640 match format {
641 OutputFormat::Json => {
642 if let Some(output) = result.output() {
643 let json_value = output.to_json();
644 result.set_out(serde_json::to_string(&json_value)
646 .unwrap_or_else(|_| "null".to_string()));
647 result.data = Some(crate::result::json_to_value(json_value));
648 } else if let Some(data) = &result.data {
649 let json_out = serde_json::to_string(&crate::result::value_to_json(data))
654 .unwrap_or_else(|_| "null".to_string());
655 result.set_out(json_out);
656 } else {
657 let text = result.text_out().into_owned();
659 let json_out = serde_json::to_string(&text)
660 .unwrap_or_else(|_| "null".to_string());
661 result.data = Some(crate::value::Value::String(text));
662 result.set_out(json_out);
663 }
664 result.set_output(None);
666 result
667 }
668 }
669}
670
671#[cfg(test)]
672mod tests {
673 use super::*;
674
675 #[test]
676 fn entry_type_variants() {
677 assert_ne!(EntryType::File, EntryType::Directory);
678 assert_ne!(EntryType::Directory, EntryType::Executable);
679 assert_ne!(EntryType::Executable, EntryType::Symlink);
680 }
681
682 #[test]
683 fn to_json_simple_text() {
684 let output = OutputData::text("hello world");
685 assert_eq!(output.to_json(), serde_json::json!("hello world"));
686 }
687
688 #[test]
689 fn to_json_flat_list() {
690 let output = OutputData::nodes(vec![
691 OutputNode::new("file1"),
692 OutputNode::new("file2"),
693 OutputNode::new("file3"),
694 ]);
695 assert_eq!(output.to_json(), serde_json::json!(["file1", "file2", "file3"]));
696 }
697
698 #[test]
699 fn to_json_table() {
700 let output = OutputData::table(
701 vec!["NAME".into(), "SIZE".into(), "TYPE".into()],
702 vec![
703 OutputNode::new("foo.rs").with_cells(vec!["1024".into(), "file".into()]),
704 OutputNode::new("bar/").with_cells(vec!["4096".into(), "dir".into()]),
705 ],
706 );
707 assert_eq!(output.to_json(), serde_json::json!([
708 {"NAME": "foo.rs", "SIZE": "1024", "TYPE": "file"},
709 {"NAME": "bar/", "SIZE": "4096", "TYPE": "dir"},
710 ]));
711 }
712
713 #[test]
714 fn to_json_table_with_children() {
715 let output = OutputData::table(
720 vec!["NAME".into(), "TYPE".into(), "SIZE".into()],
721 vec![
722 OutputNode::new(".")
723 .with_entry_type(EntryType::Directory)
724 .with_children(vec![
725 OutputNode::new("top.txt").with_cells(vec!["-".into(), "6".into()]),
726 OutputNode::new("a").with_cells(vec!["d".into(), "60".into()]),
727 ]),
728 OutputNode::new("a")
729 .with_entry_type(EntryType::Directory)
730 .with_children(vec![
731 OutputNode::new("mid.txt").with_cells(vec!["-".into(), "3".into()]),
732 ]),
733 ],
734 );
735 assert_eq!(output.to_json(), serde_json::json!([
736 {"NAME": ".", "children": [
737 {"NAME": "top.txt", "TYPE": "-", "SIZE": "6"},
738 {"NAME": "a", "TYPE": "d", "SIZE": "60"},
739 ]},
740 {"NAME": "a", "children": [
741 {"NAME": "mid.txt", "TYPE": "-", "SIZE": "3"},
742 ]},
743 ]));
744 }
745
746 #[test]
747 fn to_json_tree() {
748 let child1 = OutputNode::new("main.rs").with_entry_type(EntryType::File);
749 let child2 = OutputNode::new("utils.rs").with_entry_type(EntryType::File);
750 let subdir = OutputNode::new("lib")
751 .with_entry_type(EntryType::Directory)
752 .with_children(vec![child2]);
753 let root = OutputNode::new("src")
754 .with_entry_type(EntryType::Directory)
755 .with_children(vec![child1, subdir]);
756
757 let output = OutputData::nodes(vec![root]);
758 assert_eq!(output.to_json(), serde_json::json!({
759 "main.rs": null,
760 "lib": {"utils.rs": null},
761 }));
762 }
763
764 #[test]
765 fn to_json_tree_multiple_roots() {
766 let root1 = OutputNode::new("src")
767 .with_entry_type(EntryType::Directory)
768 .with_children(vec![OutputNode::new("main.rs")]);
769 let root2 = OutputNode::new("docs")
770 .with_entry_type(EntryType::Directory)
771 .with_children(vec![OutputNode::new("README.md")]);
772
773 let output = OutputData::nodes(vec![root1, root2]);
774 assert_eq!(output.to_json(), serde_json::json!({
775 "src": {"main.rs": null},
776 "docs": {"README.md": null},
777 }));
778 }
779
780 #[test]
781 fn to_json_empty() {
782 let output = OutputData::new();
783 assert_eq!(output.to_json(), serde_json::json!([]));
784 }
785
786 #[test]
787 fn rich_json_round_trips_through_serde() {
788 let rich = serde_json::json!({"matches": [{"line": 1, "text": "hi"}]});
793 let output = OutputData::new().with_rich_json(rich.clone());
794
795 let encoded = serde_json::to_string(&output).unwrap();
796 let decoded: OutputData = serde_json::from_str(&encoded).unwrap();
797 assert_eq!(
798 decoded.rich_json,
799 Some(rich),
800 "rich_json must survive a serde round trip"
801 );
802 }
803
804 #[test]
805 fn rich_json_none_stays_off_the_wire() {
806 let output = OutputData::nodes(vec![OutputNode::new("f")]);
810 let encoded = serde_json::to_string(&output).unwrap();
811 assert!(
812 !encoded.contains("rich_json"),
813 "a None rich_json must not serialize: {encoded}"
814 );
815 let decoded: OutputData = serde_json::from_str(&encoded).unwrap();
816 assert_eq!(decoded.rich_json, None);
817 }
818
819 #[test]
820 fn apply_output_format_clears_sentinel() {
821 let output = OutputData::table(
822 vec!["NAME".into()],
823 vec![OutputNode::new("test")],
824 );
825 let result = ExecResult::with_output(output);
826 assert!(result.has_output(), "before: sentinel present");
827
828 let formatted = apply_output_format(result, OutputFormat::Json);
829 assert!(!formatted.has_output(), "after Json: sentinel cleared");
830 }
831
832 #[test]
833 fn apply_output_format_no_double_encoding() {
834 let output = OutputData::nodes(vec![
835 OutputNode::new("file1"),
836 OutputNode::new("file2"),
837 ]);
838 let result = ExecResult::with_output(output);
839
840 let after_json = apply_output_format(result, OutputFormat::Json);
841 let json_out = after_json.text_out().into_owned();
842 assert!(!after_json.has_output(), "sentinel cleared by Json");
843
844 let parsed: serde_json::Value = serde_json::from_str(&json_out).expect("valid JSON");
845 assert_eq!(parsed, serde_json::json!(["file1", "file2"]));
846 }
847
848 #[test]
849 fn apply_output_format_populates_data() {
850 let output = OutputData::nodes(vec![
851 OutputNode::new("file1"),
852 OutputNode::new("file2"),
853 ]);
854 let result = ExecResult::with_output(output);
855 assert!(result.data.is_none(), "before: no data on non-text output");
856
857 let formatted = apply_output_format(result, OutputFormat::Json);
858 assert!(formatted.data.is_some(), "after Json: data populated");
859
860 let data = formatted.data.unwrap();
862 assert!(matches!(data, crate::value::Value::Json(_)), "data should be Json variant");
863 if let crate::value::Value::Json(json) = data {
864 assert_eq!(json, serde_json::json!(["file1", "file2"]));
865 }
866 }
867
868 #[test]
869 fn apply_output_format_prefers_structured_data_over_text() {
870 use crate::value::Value;
874 let result = ExecResult::success_with_data("1", Value::Int(1));
875 assert!(!result.has_output(), "no OutputData sentinel on this path");
876
877 let formatted = apply_output_format(result, OutputFormat::Json);
878 let json_out = formatted.text_out().into_owned();
879 let parsed: serde_json::Value = serde_json::from_str(&json_out).expect("valid JSON");
880 assert_eq!(parsed, serde_json::json!(1), "number, not the string \"1\"");
881 assert_eq!(formatted.data, Some(Value::Int(1)));
883 }
884
885 #[test]
886 fn apply_output_format_compact_json() {
887 let output = OutputData::nodes(vec![
888 OutputNode::new("file1"),
889 OutputNode::new("file2"),
890 ]);
891 let result = ExecResult::with_output(output);
892
893 let formatted = apply_output_format(result, OutputFormat::Json);
894 let out = formatted.text_out();
896 assert!(!out.contains('\n'), "should be compact JSON, got: {}", out);
897 assert_eq!(&*out, r#"["file1","file2"]"#);
898 }
899
900 #[test]
901 fn apply_output_format_emits_json_error_object_on_failure() {
902 let result = ExecResult::failure(2, "grep: unknown flag --bogus-flag");
906 assert!(!result.has_output());
907 assert!(result.text_out().is_empty());
908
909 let formatted = apply_output_format(result, OutputFormat::Json);
910 let out = formatted.text_out().into_owned();
911 let parsed: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
912 assert_eq!(
913 parsed,
914 serde_json::json!({"error": "grep: unknown flag --bogus-flag", "code": 2})
915 );
916 assert!(matches!(formatted.data, Some(crate::value::Value::Json(_))));
918 }
919
920 #[test]
921 fn apply_output_format_preserves_structured_data_on_error() {
922 let mut result = ExecResult::failure(2, "rm: confirmation required (latch enabled)");
927 result.data = Some(crate::value::Value::Json(serde_json::json!({
928 "nonce": "a3f7b2c1",
929 "command": "rm",
930 "paths": ["important.dat"],
931 "hint": "rm --confirm=\"a3f7b2c1\" important.dat",
932 "ttl": 60,
933 })));
934
935 let formatted = apply_output_format(result, OutputFormat::Json);
936 let out = formatted.text_out().into_owned();
937 let parsed: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
938 assert_eq!(parsed["error"], "rm: confirmation required (latch enabled)");
939 assert_eq!(parsed["code"], 2);
940 assert_eq!(parsed["data"]["nonce"], "a3f7b2c1");
941 assert_eq!(parsed["data"]["ttl"], 60);
942 match &formatted.data {
944 Some(crate::value::Value::Json(v)) => assert_eq!(v["data"]["nonce"], "a3f7b2c1"),
945 other => panic!("expected nested JSON data, got {other:?}"),
946 }
947 }
948
949 #[test]
950 fn apply_output_format_surfaces_latch_even_when_result_has_output() {
951 let mut result = ExecResult::from_output(2, "[1] Latched\n", "");
959 result.set_output(Some(OutputData::text("[1] Latched\n")));
960 assert!(result.has_output(), "precondition: this result DOES have output");
961 result.latch = Some(Box::new(LatchRequest {
962 nonce: "a3f7b2c1".to_string(),
963 command: "rm".to_string(),
964 paths: vec!["precious.txt".to_string()],
965 hint: "rm --confirm=\"a3f7b2c1\" precious.txt".to_string(),
966 tool: "rm".to_string(),
967 argv: vec!["precious.txt".to_string()],
968 ttl: 60,
969 job_id: None,
970 }));
971
972 let formatted = apply_output_format(result, OutputFormat::Json);
973 let out = formatted.text_out().into_owned();
974 let parsed: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
975 assert_eq!(
976 parsed["latch"]["nonce"], "a3f7b2c1",
977 "the nonce must be reachable from a latched result that also has \
978 output, not just the no-output error path: {parsed}"
979 );
980 assert_eq!(parsed["latch"]["command"], "rm");
981 assert_eq!(parsed["code"], 2);
982 assert_eq!(parsed["error"], "[1] Latched\n");
985 assert!(
986 formatted.latch_request().is_some(),
987 "the typed latch must survive --json formatting"
988 );
989 }
990
991 #[test]
992 fn apply_output_format_leaves_clean_no_match_empty() {
993 let result = ExecResult::failure(1, "");
996 let formatted = apply_output_format(result, OutputFormat::Json);
997 assert!(formatted.text_out().is_empty());
998 assert!(formatted.data.is_none());
999 }
1000
1001 #[test]
1002 fn apply_output_format_empty_success_stays_empty() {
1003 let result = ExecResult::success("");
1004 let formatted = apply_output_format(result, OutputFormat::Json);
1005 assert!(formatted.text_out().is_empty());
1006 assert!(formatted.data.is_none());
1007 }
1008
1009 #[test]
1010 fn estimated_byte_size_text_only_node() {
1011 let node = OutputNode::text("hello world");
1012 assert_eq!(node.estimated_byte_size(), 11);
1014 }
1015
1016 #[test]
1017 fn estimated_byte_size_named_node() {
1018 let node = OutputNode::new("file.txt");
1019 assert_eq!(node.estimated_byte_size(), 8);
1020 }
1021
1022 #[test]
1023 fn write_canonical_respects_budget() {
1024 let parent = OutputNode::new("root")
1025 .with_children(vec![
1026 OutputNode::new("aaaa"),
1027 OutputNode::new("bbbb"),
1028 OutputNode::new("cccc"),
1029 ]);
1030 let mut buf = Vec::new();
1032 let written = parent.write_canonical(&mut buf, 8).unwrap();
1033 let output = String::from_utf8(buf).unwrap();
1034 assert!(written <= 16, "should respect budget, wrote {} bytes: {}", written, output);
1036 assert!(output.starts_with("root"), "should start with root: {}", output);
1038 }
1039
1040 #[test]
1041 fn into_text_simple() {
1042 let data = OutputData::text("hello");
1043 assert_eq!(data.into_text(), Ok("hello".to_string()));
1044 }
1045
1046 #[test]
1047 fn into_text_non_simple() {
1048 let data = OutputData::nodes(vec![OutputNode::new("a"), OutputNode::new("b")]);
1049 assert!(data.into_text().is_err());
1050 }
1051
1052 #[test]
1053 fn into_text_empty() {
1054 let data = OutputData::text("");
1055 assert_eq!(data.into_text(), Ok("".to_string()));
1056 }
1057}