1use serde_json::Value;
2
3use crate::config::OutputMode;
4use crate::error::AppError;
5use crate::services::OutputEnvelope;
6
7pub fn render_output(mode: OutputMode, envelope: &OutputEnvelope) -> Result<String, AppError> {
8 match mode {
9 OutputMode::Json => Ok(serde_json::to_string_pretty(envelope)?),
10 OutputMode::Markdown => Ok(render_markdown(envelope)),
11 OutputMode::Tui => Err(AppError::Message(
12 "TUI output is only available in interactive mode.".to_string(),
13 )),
14 }
15}
16
17pub fn render_markdown(envelope: &OutputEnvelope) -> String {
18 if matches!(
19 envelope.command.as_str(),
20 "documents get" | "documents content" | "pdf read"
21 ) {
22 if let Some(text) = extract_document_text(&envelope.data) {
23 return format!("{text}\n");
24 }
25 }
26
27 let mut lines = Vec::new();
28 render_value_as_markdown(&mut lines, &envelope.data, 0);
29
30 if !envelope.security.is_empty() {
31 lines.push(String::new());
32 lines.push("## Security".to_string());
33 lines.push(String::new());
34 for finding in &envelope.security {
35 lines.push(format!(
36 "- `{}` {}: {}",
37 severity_label(finding.severity.as_ref()),
38 finding.title,
39 finding.detail
40 ));
41 lines.push(format!(" Remediation: {}", finding.remediation));
42 }
43 }
44
45 lines.join("\n")
46}
47
48fn extract_document_text(value: &Value) -> Option<String> {
49 let object = value.as_object()?;
50 let candidate = ["content", "text", "document_text", "body"]
51 .iter()
52 .find_map(|key| object.get(*key).and_then(Value::as_str))?;
53 let trimmed = candidate.trim();
54 if trimmed.is_empty() {
55 None
56 } else {
57 Some(trimmed.to_string())
58 }
59}
60
61fn render_value_as_markdown(lines: &mut Vec<String>, value: &Value, depth: usize) {
62 let indent = " ".repeat(depth);
63 match value {
64 Value::Null => lines.push(format!("{indent}- null")),
65 Value::Bool(boolean) => lines.push(format!("{indent}- `{boolean}`")),
66 Value::Number(number) => lines.push(format!("{indent}- `{number}`")),
67 Value::String(text) => lines.push(format!("{indent}- {}", text)),
68 Value::Array(items) => {
69 if items.is_empty() {
70 lines.push(format!("{indent}- []"));
71 return;
72 }
73
74 if let Some(table) = try_render_table(items) {
75 lines.extend(table);
76 return;
77 }
78
79 for item in items {
80 render_value_as_markdown(lines, item, depth + 1);
81 }
82 }
83 Value::Object(map) => {
84 if map.is_empty() {
85 lines.push(format!("{indent}- {{}}"));
86 return;
87 }
88
89 if let Some(results) = map.get("results").and_then(Value::as_array) {
90 for (key, nested) in map {
91 if key == "results" {
92 continue;
93 }
94
95 match nested {
96 Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => {
97 let inline = nested
98 .as_str()
99 .map(str::to_string)
100 .unwrap_or_else(|| nested.to_string());
101 lines.push(format!("{indent}- **{key}**: {inline}"));
102 }
103 _ => {
104 lines.push(format!("{indent}- **{key}**:"));
105 render_value_as_markdown(lines, nested, depth + 1);
106 }
107 }
108 }
109
110 lines.push(format!("{indent}- **results**:"));
111 if let Some(table) = try_render_table(results) {
112 lines.extend(table);
113 } else {
114 render_value_as_markdown(lines, &Value::Array(results.clone()), depth + 1);
115 }
116 return;
117 }
118
119 for (key, nested) in map {
120 match nested {
121 Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => {
122 let inline = nested
123 .as_str()
124 .map(str::to_string)
125 .unwrap_or_else(|| nested.to_string());
126 lines.push(format!("{indent}- **{key}**: {inline}"));
127 }
128 _ => {
129 lines.push(format!("{indent}- **{key}**:"));
130 render_value_as_markdown(lines, nested, depth + 1);
131 }
132 }
133 }
134 }
135 }
136}
137
138fn try_render_table(items: &[Value]) -> Option<Vec<String>> {
139 let first = items.first()?.as_object()?;
140 let preferred = ["id", "title", "name", "created", "status"];
141 let mut headers = preferred
142 .iter()
143 .filter(|key| first.contains_key(**key))
144 .map(|key| key.to_string())
145 .collect::<Vec<_>>();
146
147 for key in first.keys() {
148 if !headers.iter().any(|existing| existing == key) {
149 headers.push(key.clone());
150 }
151 if headers.len() >= 6 {
152 break;
153 }
154 }
155
156 if headers.is_empty() {
157 return None;
158 }
159
160 let mut lines = vec![
161 format!("| {} |", headers.join(" | ")),
162 format!(
163 "| {} |",
164 headers
165 .iter()
166 .map(|_| "---")
167 .collect::<Vec<_>>()
168 .join(" | ")
169 ),
170 ];
171
172 for item in items {
173 let row = item.as_object()?;
174 let values = headers
175 .iter()
176 .map(|header| {
177 row.get(header)
178 .map(|value| match value {
179 Value::String(text) => text.replace('|', "\\|"),
180 _ => value.to_string().replace('|', "\\|"),
181 })
182 .unwrap_or_default()
183 })
184 .collect::<Vec<_>>();
185 lines.push(format!("| {} |", values.join(" | ")));
186 }
187
188 Some(lines)
189}
190
191fn severity_label(severity: &str) -> &'static str {
192 match severity {
193 "critical" => "CRITICAL",
194 "high" => "HIGH",
195 "medium" => "MEDIUM",
196 _ => "LOW",
197 }
198}
199
200trait SeverityName {
201 fn as_ref(&self) -> &str;
202}
203
204impl SeverityName for crate::security::Severity {
205 fn as_ref(&self) -> &str {
206 match self {
207 crate::security::Severity::Critical => "critical",
208 crate::security::Severity::High => "high",
209 crate::security::Severity::Medium => "medium",
210 crate::security::Severity::Low => "low",
211 }
212 }
213}