use serde::{Deserialize, Serialize};
use crate::result::ExecResult;
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "lowercase")]
pub enum EntryType {
#[default]
Text,
File,
Directory,
Executable,
Symlink,
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(default)]
pub struct OutputNode {
pub name: String,
pub entry_type: EntryType,
pub text: Option<String>,
pub cells: Vec<String>,
pub children: Vec<OutputNode>,
}
impl OutputNode {
pub fn new(name: impl Into<String>) -> Self {
Self {
name: name.into(),
..Default::default()
}
}
pub fn text(content: impl Into<String>) -> Self {
Self {
text: Some(content.into()),
..Default::default()
}
}
pub fn with_entry_type(mut self, entry_type: EntryType) -> Self {
self.entry_type = entry_type;
self
}
pub fn with_cells(mut self, cells: Vec<String>) -> Self {
self.cells = cells;
self
}
pub fn with_children(mut self, children: Vec<OutputNode>) -> Self {
self.children = children;
self
}
pub fn with_text(mut self, text: impl Into<String>) -> Self {
self.text = Some(text.into());
self
}
pub fn is_text_only(&self) -> bool {
self.text.is_some() && self.name.is_empty() && self.cells.is_empty() && self.children.is_empty()
}
pub fn has_children(&self) -> bool {
!self.children.is_empty()
}
pub fn estimated_byte_size(&self) -> usize {
if self.children.is_empty() {
self.name.len() + self.text.as_ref().map_or(0, |t| t.len())
} else {
let mut size = self.name.len() + 2; for (i, child) in self.children.iter().enumerate() {
if i > 0 {
size += 1; }
size += child.estimated_byte_size();
}
size + 1 }
}
pub fn write_canonical(&self, w: &mut dyn std::io::Write, budget: usize) -> std::io::Result<usize> {
if self.children.is_empty() {
w.write_all(self.name.as_bytes())?;
return Ok(self.name.len());
}
let mut written = 0;
w.write_all(self.name.as_bytes())?;
written += self.name.len();
if written >= budget {
return Ok(written);
}
w.write_all(b"/{")?;
written += 2;
for (i, child) in self.children.iter().enumerate() {
if written >= budget {
break;
}
if i > 0 {
w.write_all(b",")?;
written += 1;
}
written += child.write_canonical(w, budget.saturating_sub(written))?;
}
w.write_all(b"}")?;
written += 1;
Ok(written)
}
pub fn display_name(&self) -> &str {
if self.name.is_empty() {
self.text.as_deref().unwrap_or("")
} else {
&self.name
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(default)]
pub struct OutputData {
pub headers: Option<Vec<String>>,
pub root: Vec<OutputNode>,
#[serde(skip)]
#[cfg_attr(feature = "schema", schemars(skip))]
pub rich_json: Option<serde_json::Value>,
}
impl OutputData {
pub fn new() -> Self {
Self::default()
}
pub fn text(content: impl Into<String>) -> Self {
Self {
headers: None,
root: vec![OutputNode::text(content)],
rich_json: None,
}
}
pub fn nodes(nodes: Vec<OutputNode>) -> Self {
Self {
headers: None,
root: nodes,
rich_json: None,
}
}
pub fn table(headers: Vec<String>, nodes: Vec<OutputNode>) -> Self {
Self {
headers: Some(headers),
root: nodes,
rich_json: None,
}
}
pub fn with_headers(mut self, headers: Vec<String>) -> Self {
self.headers = Some(headers);
self
}
pub fn with_rich_json(mut self, value: serde_json::Value) -> Self {
self.rich_json = Some(value);
self
}
pub fn is_simple_text(&self) -> bool {
self.root.len() == 1 && self.root[0].is_text_only()
}
pub fn is_flat(&self) -> bool {
self.root.iter().all(|n| !n.has_children())
}
pub fn is_tabular(&self) -> bool {
self.root.iter().any(|n| !n.cells.is_empty())
}
pub fn as_text(&self) -> Option<&str> {
if self.is_simple_text() {
self.root[0].text.as_deref()
} else {
None
}
}
pub fn into_text(mut self) -> Result<String, Self> {
if self.root.len() == 1 && self.root[0].is_text_only() {
Ok(self.root.pop().and_then(|n| n.text).unwrap_or_default())
} else {
Err(self)
}
}
pub fn estimated_byte_size(&self) -> usize {
if self.root.len() == 1 && self.root[0].is_text_only() {
return self.root[0].text.as_ref().map_or(0, |t| t.len());
}
if self.is_flat() {
let mut size = 0;
for (i, n) in self.root.iter().enumerate() {
if i > 0 {
size += 1; }
size += n.display_name().len();
for cell in &n.cells {
size += 1 + cell.len(); }
}
return size;
}
let mut size = 0;
for (i, n) in self.root.iter().enumerate() {
if i > 0 {
size += 1; }
size += n.estimated_byte_size();
}
size
}
pub fn write_canonical(&self, w: &mut dyn std::io::Write, budget: Option<usize>) -> std::io::Result<usize> {
let mut written = 0usize;
let budget = budget.unwrap_or(usize::MAX);
if self.root.len() == 1 && self.root[0].is_text_only() {
if let Some(ref text) = self.root[0].text {
w.write_all(text.as_bytes())?;
return Ok(text.len());
}
return Ok(0);
}
if self.is_flat() {
for (i, n) in self.root.iter().enumerate() {
if i > 0 {
w.write_all(b"\n")?;
written += 1;
}
let name = n.display_name();
w.write_all(name.as_bytes())?;
written += name.len();
for cell in &n.cells {
w.write_all(b"\t")?;
w.write_all(cell.as_bytes())?;
written += 1 + cell.len();
}
if written > budget {
return Ok(written);
}
}
return Ok(written);
}
for (i, n) in self.root.iter().enumerate() {
if i > 0 {
w.write_all(b"\n")?;
written += 1;
}
written += n.write_canonical(w, budget.saturating_sub(written))?;
if written > budget {
return Ok(written);
}
}
Ok(written)
}
pub fn to_canonical_string(&self) -> String {
if let Some(text) = self.as_text() {
return text.to_string();
}
if self.is_flat() {
return self.root.iter()
.map(|n| {
if n.cells.is_empty() {
n.display_name().to_string()
} else {
let mut parts = vec![n.display_name().to_string()];
parts.extend(n.cells.iter().cloned());
parts.join("\t")
}
})
.collect::<Vec<_>>()
.join("\n");
}
fn format_node(node: &OutputNode) -> String {
if node.children.is_empty() {
node.name.clone()
} else {
let children: Vec<String> = node.children.iter()
.map(format_node)
.collect();
format!("{}/{{{}}}", node.name, children.join(","))
}
}
self.root.iter()
.map(format_node)
.collect::<Vec<_>>()
.join("\n")
}
pub fn to_json(&self) -> serde_json::Value {
if let Some(rich) = &self.rich_json {
return rich.clone();
}
if let Some(text) = self.as_text() {
return serde_json::Value::String(text.to_string());
}
if let Some(ref headers) = self.headers {
let rows: Vec<serde_json::Value> = self.root.iter().map(|node| {
let mut map = serde_json::Map::new();
if let Some(first) = headers.first() {
map.insert(first.clone(), serde_json::Value::String(node.name.clone()));
}
for (header, cell) in headers.iter().skip(1).zip(node.cells.iter()) {
map.insert(header.clone(), serde_json::Value::String(cell.clone()));
}
serde_json::Value::Object(map)
}).collect();
return serde_json::Value::Array(rows);
}
if !self.is_flat() {
fn node_to_json(node: &OutputNode) -> serde_json::Value {
if node.children.is_empty() {
serde_json::Value::Null
} else {
let mut map = serde_json::Map::new();
for child in &node.children {
map.insert(child.name.clone(), node_to_json(child));
}
serde_json::Value::Object(map)
}
}
if self.root.len() == 1 {
return node_to_json(&self.root[0]);
}
let mut map = serde_json::Map::new();
for node in &self.root {
map.insert(node.name.clone(), node_to_json(node));
}
return serde_json::Value::Object(map);
}
let items: Vec<serde_json::Value> = self.root.iter()
.map(|n| serde_json::Value::String(n.display_name().to_string()))
.collect();
serde_json::Value::Array(items)
}
}
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OutputFormat {
Json,
}
pub fn apply_output_format(mut result: ExecResult, format: OutputFormat) -> ExecResult {
if !result.has_output() && result.text_out().is_empty() {
return result;
}
match format {
OutputFormat::Json => {
if let Some(output) = result.output() {
let json_value = output.to_json();
result.set_out(serde_json::to_string(&json_value)
.unwrap_or_else(|_| "null".to_string()));
result.data = Some(crate::result::json_to_value(json_value));
} else {
let text = result.text_out().into_owned();
let json_out = serde_json::to_string(&text)
.unwrap_or_else(|_| "null".to_string());
result.data = Some(crate::value::Value::String(text));
result.set_out(json_out);
}
result.set_output(None);
result
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn entry_type_variants() {
assert_ne!(EntryType::File, EntryType::Directory);
assert_ne!(EntryType::Directory, EntryType::Executable);
assert_ne!(EntryType::Executable, EntryType::Symlink);
}
#[test]
fn to_json_simple_text() {
let output = OutputData::text("hello world");
assert_eq!(output.to_json(), serde_json::json!("hello world"));
}
#[test]
fn to_json_flat_list() {
let output = OutputData::nodes(vec![
OutputNode::new("file1"),
OutputNode::new("file2"),
OutputNode::new("file3"),
]);
assert_eq!(output.to_json(), serde_json::json!(["file1", "file2", "file3"]));
}
#[test]
fn to_json_table() {
let output = OutputData::table(
vec!["NAME".into(), "SIZE".into(), "TYPE".into()],
vec![
OutputNode::new("foo.rs").with_cells(vec!["1024".into(), "file".into()]),
OutputNode::new("bar/").with_cells(vec!["4096".into(), "dir".into()]),
],
);
assert_eq!(output.to_json(), serde_json::json!([
{"NAME": "foo.rs", "SIZE": "1024", "TYPE": "file"},
{"NAME": "bar/", "SIZE": "4096", "TYPE": "dir"},
]));
}
#[test]
fn to_json_tree() {
let child1 = OutputNode::new("main.rs").with_entry_type(EntryType::File);
let child2 = OutputNode::new("utils.rs").with_entry_type(EntryType::File);
let subdir = OutputNode::new("lib")
.with_entry_type(EntryType::Directory)
.with_children(vec![child2]);
let root = OutputNode::new("src")
.with_entry_type(EntryType::Directory)
.with_children(vec![child1, subdir]);
let output = OutputData::nodes(vec![root]);
assert_eq!(output.to_json(), serde_json::json!({
"main.rs": null,
"lib": {"utils.rs": null},
}));
}
#[test]
fn to_json_tree_multiple_roots() {
let root1 = OutputNode::new("src")
.with_entry_type(EntryType::Directory)
.with_children(vec![OutputNode::new("main.rs")]);
let root2 = OutputNode::new("docs")
.with_entry_type(EntryType::Directory)
.with_children(vec![OutputNode::new("README.md")]);
let output = OutputData::nodes(vec![root1, root2]);
assert_eq!(output.to_json(), serde_json::json!({
"src": {"main.rs": null},
"docs": {"README.md": null},
}));
}
#[test]
fn to_json_empty() {
let output = OutputData::new();
assert_eq!(output.to_json(), serde_json::json!([]));
}
#[test]
fn apply_output_format_clears_sentinel() {
let output = OutputData::table(
vec!["NAME".into()],
vec![OutputNode::new("test")],
);
let result = ExecResult::with_output(output);
assert!(result.has_output(), "before: sentinel present");
let formatted = apply_output_format(result, OutputFormat::Json);
assert!(!formatted.has_output(), "after Json: sentinel cleared");
}
#[test]
fn apply_output_format_no_double_encoding() {
let output = OutputData::nodes(vec![
OutputNode::new("file1"),
OutputNode::new("file2"),
]);
let result = ExecResult::with_output(output);
let after_json = apply_output_format(result, OutputFormat::Json);
let json_out = after_json.text_out().into_owned();
assert!(!after_json.has_output(), "sentinel cleared by Json");
let parsed: serde_json::Value = serde_json::from_str(&json_out).expect("valid JSON");
assert_eq!(parsed, serde_json::json!(["file1", "file2"]));
}
#[test]
fn apply_output_format_populates_data() {
let output = OutputData::nodes(vec![
OutputNode::new("file1"),
OutputNode::new("file2"),
]);
let result = ExecResult::with_output(output);
assert!(result.data.is_none(), "before: no data on non-text output");
let formatted = apply_output_format(result, OutputFormat::Json);
assert!(formatted.data.is_some(), "after Json: data populated");
let data = formatted.data.unwrap();
assert!(matches!(data, crate::value::Value::Json(_)), "data should be Json variant");
if let crate::value::Value::Json(json) = data {
assert_eq!(json, serde_json::json!(["file1", "file2"]));
}
}
#[test]
fn apply_output_format_compact_json() {
let output = OutputData::nodes(vec![
OutputNode::new("file1"),
OutputNode::new("file2"),
]);
let result = ExecResult::with_output(output);
let formatted = apply_output_format(result, OutputFormat::Json);
let out = formatted.text_out();
assert!(!out.contains('\n'), "should be compact JSON, got: {}", out);
assert_eq!(&*out, r#"["file1","file2"]"#);
}
#[test]
fn estimated_byte_size_text_only_node() {
let node = OutputNode::text("hello world");
assert_eq!(node.estimated_byte_size(), 11);
}
#[test]
fn estimated_byte_size_named_node() {
let node = OutputNode::new("file.txt");
assert_eq!(node.estimated_byte_size(), 8);
}
#[test]
fn write_canonical_respects_budget() {
let parent = OutputNode::new("root")
.with_children(vec![
OutputNode::new("aaaa"),
OutputNode::new("bbbb"),
OutputNode::new("cccc"),
]);
let mut buf = Vec::new();
let written = parent.write_canonical(&mut buf, 8).unwrap();
let output = String::from_utf8(buf).unwrap();
assert!(written <= 16, "should respect budget, wrote {} bytes: {}", written, output);
assert!(output.starts_with("root"), "should start with root: {}", output);
}
#[test]
fn into_text_simple() {
let data = OutputData::text("hello");
assert_eq!(data.into_text(), Ok("hello".to_string()));
}
#[test]
fn into_text_non_simple() {
let data = OutputData::nodes(vec![OutputNode::new("a"), OutputNode::new("b")]);
assert!(data.into_text().is_err());
}
#[test]
fn into_text_empty() {
let data = OutputData::text("");
assert_eq!(data.into_text(), Ok("".to_string()));
}
}