Skip to main content

paperless_cli/
render.rs

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}