Skip to main content

ctx/
formatter.rs

1//! Output formatters for generated context.
2//!
3//! Renders discovered files into one of four formats, selected via
4//! [`OutputFormat`]: XML (default, best for most LLMs), Markdown, JSON,
5//! or plain text. Use [`get_formatter`] to obtain the right
6//! [`Formatter`] implementation for a format.
7
8use std::path::Path;
9
10use crate::walker::FileEntry;
11
12/// Trait for formatting context output.
13pub trait Formatter {
14    /// Format the project tree block.
15    fn format_tree(&self, tree: &str) -> String;
16
17    /// Format a single file block.
18    fn format_file(&self, entry: &FileEntry, content: &str) -> String;
19
20    /// Wrap the tree block and files block into final output.
21    fn wrap(&self, tree_block: Option<&str>, files_block: &str) -> String;
22
23    /// Get the opening wrapper for streaming output.
24    fn stream_start(&self, tree_block: Option<&str>) -> String;
25
26    /// Get the closing wrapper for streaming output.
27    fn stream_end(&self) -> String;
28
29    /// Get the separator between file blocks.
30    fn separator(&self) -> &'static str;
31}
32
33/// XML formatter.
34pub struct XmlFormatter;
35
36impl XmlFormatter {
37    /// Escape special XML characters in text content.
38    /// Order matters: & must be escaped first to avoid double-escaping.
39    fn escape_xml_text(s: &str) -> String {
40        s.replace('&', "&amp;")
41            .replace('<', "&lt;")
42            .replace('>', "&gt;")
43    }
44
45    /// Escape special characters in XML attribute values.
46    /// Includes quotes in addition to text escapes.
47    fn escape_xml_attr(s: &str) -> String {
48        s.replace('&', "&amp;")
49            .replace('<', "&lt;")
50            .replace('>', "&gt;")
51            .replace('"', "&quot;")
52            .replace('\'', "&apos;")
53    }
54}
55
56impl Formatter for XmlFormatter {
57    fn format_tree(&self, tree: &str) -> String {
58        format!(
59            "<project_tree>\n{}</project_tree>",
60            Self::escape_xml_text(tree)
61        )
62    }
63
64    fn format_file(&self, entry: &FileEntry, content: &str) -> String {
65        let filename = entry
66            .relative_path
67            .file_name()
68            .map(|s| s.to_string_lossy())
69            .unwrap_or_default();
70
71        let path = format_path_for_output(&entry.relative_path);
72
73        format!(
74            "<file name=\"{}\" path=\"{}\">\n{}\n</file>",
75            Self::escape_xml_attr(&filename),
76            Self::escape_xml_attr(&path),
77            Self::escape_xml_text(content.trim())
78        )
79    }
80
81    fn wrap(&self, tree_block: Option<&str>, files_block: &str) -> String {
82        match tree_block {
83            Some(tree) => format!(
84                "<context>\n{}\n<project_files>\n{}\n</project_files>\n</context>",
85                tree, files_block
86            ),
87            None => format!(
88                "<context>\n<project_files>\n{}\n</project_files>\n</context>",
89                files_block
90            ),
91        }
92    }
93
94    fn stream_start(&self, tree_block: Option<&str>) -> String {
95        match tree_block {
96            Some(tree) => format!("<context>\n{}\n<project_files>", tree),
97            None => "<context>\n<project_files>".to_string(),
98        }
99    }
100
101    fn stream_end(&self) -> String {
102        "</project_files>\n</context>".to_string()
103    }
104
105    fn separator(&self) -> &'static str {
106        "\n"
107    }
108}
109
110/// Markdown formatter.
111pub struct MarkdownFormatter;
112
113impl Formatter for MarkdownFormatter {
114    fn format_tree(&self, tree: &str) -> String {
115        format!("## Project Tree\n\n```\n{}```", tree)
116    }
117
118    fn format_file(&self, entry: &FileEntry, content: &str) -> String {
119        let path = format_path_for_output(&entry.relative_path);
120        let extension = entry
121            .relative_path
122            .extension()
123            .map(|s| s.to_string_lossy())
124            .unwrap_or_default();
125
126        format!("## {}\n\n```{}\n{}\n```", path, extension, content.trim())
127    }
128
129    fn wrap(&self, tree_block: Option<&str>, files_block: &str) -> String {
130        match tree_block {
131            Some(tree) => format!("# Project Context\n\n{}\n\n{}", tree, files_block),
132            None => format!("# Project Context\n\n{}", files_block),
133        }
134    }
135
136    fn stream_start(&self, tree_block: Option<&str>) -> String {
137        match tree_block {
138            Some(tree) => format!("# Project Context\n\n{}\n", tree),
139            None => "# Project Context\n".to_string(),
140        }
141    }
142
143    fn stream_end(&self) -> String {
144        String::new()
145    }
146
147    fn separator(&self) -> &'static str {
148        "\n\n"
149    }
150}
151
152/// Plain text formatter.
153pub struct PlainFormatter;
154
155impl Formatter for PlainFormatter {
156    fn format_tree(&self, tree: &str) -> String {
157        format!("=== PROJECT TREE ===\n\n{}", tree)
158    }
159
160    fn format_file(&self, entry: &FileEntry, content: &str) -> String {
161        let path = format_path_for_output(&entry.relative_path);
162        format!("=== {} ===\n\n{}\n", path, content.trim())
163    }
164
165    fn wrap(&self, tree_block: Option<&str>, files_block: &str) -> String {
166        match tree_block {
167            Some(tree) => format!("{}\n{}", tree, files_block),
168            None => files_block.to_string(),
169        }
170    }
171
172    fn stream_start(&self, tree_block: Option<&str>) -> String {
173        match tree_block {
174            Some(tree) => format!("{}\n", tree),
175            None => String::new(),
176        }
177    }
178
179    fn stream_end(&self) -> String {
180        String::new()
181    }
182
183    fn separator(&self) -> &'static str {
184        "\n"
185    }
186}
187
188/// JSON formatter.
189///
190/// Outputs structured JSON with the format:
191/// ```json
192/// {
193///   "tree": "...",
194///   "files": [
195///     { "name": "main.rs", "path": "/src/main.rs", "content": "..." }
196///   ]
197/// }
198/// ```
199///
200/// Note: JSON streaming outputs newline-delimited JSON objects (NDJSON) for each file,
201/// since partial JSON arrays aren't valid JSON.
202pub struct JsonFormatter;
203
204impl JsonFormatter {
205    /// Escape a string for JSON (handles control characters, quotes, backslashes)
206    fn escape_json_string(s: &str) -> String {
207        serde_json::to_string(s).unwrap_or_else(|_| format!("\"{}\"", s))
208    }
209}
210
211impl Formatter for JsonFormatter {
212    fn format_tree(&self, tree: &str) -> String {
213        // For JSON, tree is embedded in the wrapper, not standalone
214        Self::escape_json_string(tree)
215    }
216
217    fn format_file(&self, entry: &FileEntry, content: &str) -> String {
218        let filename = entry
219            .relative_path
220            .file_name()
221            .map(|s| s.to_string_lossy().to_string())
222            .unwrap_or_default();
223
224        let path = format_path_for_output(&entry.relative_path);
225
226        // Create a JSON object for this file
227        format!(
228            r#"{{"name":{},"path":{},"content":{}}}"#,
229            Self::escape_json_string(&filename),
230            Self::escape_json_string(&path),
231            Self::escape_json_string(content.trim())
232        )
233    }
234
235    fn wrap(&self, tree_block: Option<&str>, files_block: &str) -> String {
236        // files_block contains comma-separated JSON objects
237        // Wrap them in an array and add tree if present
238        match tree_block {
239            Some(tree) => format!(
240                r#"{{"tree":{},"files":[{}]}}"#,
241                tree, // Already JSON-escaped from format_tree
242                files_block
243            ),
244            None => format!(r#"{{"files":[{}]}}"#, files_block),
245        }
246    }
247
248    fn stream_start(&self, tree_block: Option<&str>) -> String {
249        // For streaming, output NDJSON format (one JSON object per line)
250        // Start with a metadata object containing the tree
251        match tree_block {
252            Some(tree) => format!(r#"{{"type":"tree","content":{}}}"#, tree),
253            None => String::new(),
254        }
255    }
256
257    fn stream_end(&self) -> String {
258        // For NDJSON, no closing tag needed
259        String::new()
260    }
261
262    fn separator(&self) -> &'static str {
263        // For NDJSON streaming, use newline separator
264        "\n"
265    }
266}
267
268/// Format a path for output (use forward slashes, prefix with /).
269fn format_path_for_output(path: &Path) -> String {
270    let path_str = path.to_string_lossy().replace('\\', "/");
271    if path_str.starts_with('/') {
272        path_str.to_string()
273    } else {
274        format!("/{}", path_str)
275    }
276}
277
278/// Output format for context generation.
279#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
280pub enum OutputFormat {
281    /// XML format (default)
282    #[default]
283    Xml,
284    /// Markdown format
285    Markdown,
286    /// Plain text format
287    Plain,
288    /// JSON format
289    Json,
290}
291
292impl OutputFormat {}
293
294impl std::str::FromStr for OutputFormat {
295    type Err = ();
296    fn from_str(s: &str) -> Result<Self, Self::Err> {
297        match s.to_lowercase().as_str() {
298            "xml" => Ok(Self::Xml),
299            "markdown" | "md" => Ok(Self::Markdown),
300            "plain" => Ok(Self::Plain),
301            "json" => Ok(Self::Json),
302            _ => Err(()),
303        }
304    }
305}
306
307/// Get a formatter instance based on format.
308pub fn get_formatter(format: OutputFormat) -> Box<dyn Formatter> {
309    match format {
310        OutputFormat::Xml => Box::new(XmlFormatter),
311        OutputFormat::Markdown => Box::new(MarkdownFormatter),
312        OutputFormat::Plain => Box::new(PlainFormatter),
313        OutputFormat::Json => Box::new(JsonFormatter),
314    }
315}
316
317#[cfg(test)]
318mod tests {
319    use super::*;
320    use std::path::PathBuf;
321
322    fn make_entry(rel_path: &str) -> FileEntry {
323        FileEntry {
324            absolute_path: PathBuf::from("/project").join(rel_path),
325            relative_path: PathBuf::from(rel_path),
326            size: 100,
327        }
328    }
329
330    #[test]
331    fn test_xml_formatter() {
332        let formatter = XmlFormatter;
333        let entry = make_entry("src/main.rs");
334        let output = formatter.format_file(&entry, "fn main() {}");
335
336        assert!(output.contains("<file name=\"main.rs\""));
337        assert!(output.contains("path=\"/src/main.rs\""));
338        assert!(output.contains("fn main() {}"));
339    }
340
341    #[test]
342    fn test_xml_formatter_escapes_content() {
343        let formatter = XmlFormatter;
344        let entry = make_entry("src/test.rs");
345        // Content with XML special characters
346        let content = r#"if x < 10 && y > 5 { println!("<tag>"); }"#;
347        let output = formatter.format_file(&entry, content);
348
349        // Verify special characters are escaped
350        assert!(output.contains("x &lt; 10"));
351        assert!(output.contains("&amp;&amp;"));
352        assert!(output.contains("y &gt; 5"));
353        assert!(output.contains("&lt;tag&gt;"));
354        // Raw characters should NOT appear in content
355        assert!(!output.contains("< 10"));
356        assert!(!output.contains("> 5"));
357    }
358
359    #[test]
360    fn test_xml_formatter_escapes_attributes() {
361        let formatter = XmlFormatter;
362        // File path with special characters (edge case)
363        let entry = make_entry("src/test&file.rs");
364        let output = formatter.format_file(&entry, "content");
365
366        // Verify ampersand in filename is escaped
367        assert!(output.contains("name=\"test&amp;file.rs\""));
368        assert!(output.contains("path=\"/src/test&amp;file.rs\""));
369    }
370
371    #[test]
372    fn test_xml_formatter_escapes_quotes_in_attrs() {
373        // Test the escape function directly since filenames with quotes are rare
374        let escaped = XmlFormatter::escape_xml_attr(r#"file"name'test"#);
375        assert_eq!(escaped, "file&quot;name&apos;test");
376    }
377
378    #[test]
379    fn test_xml_formatter_escapes_tree() {
380        let formatter = XmlFormatter;
381        let tree = "src/\n  <generated>/\n  test&file.rs";
382        let output = formatter.format_tree(tree);
383
384        assert!(output.contains("&lt;generated&gt;"));
385        assert!(output.contains("test&amp;file.rs"));
386    }
387
388    #[test]
389    fn test_markdown_formatter() {
390        let formatter = MarkdownFormatter;
391        let entry = make_entry("src/main.rs");
392        let output = formatter.format_file(&entry, "fn main() {}");
393
394        assert!(output.contains("## /src/main.rs"));
395        assert!(output.contains("```rs"));
396        assert!(output.contains("fn main() {}"));
397    }
398
399    #[test]
400    fn test_plain_formatter() {
401        let formatter = PlainFormatter;
402        let entry = make_entry("src/main.rs");
403        let output = formatter.format_file(&entry, "fn main() {}");
404
405        assert!(output.contains("=== /src/main.rs ==="));
406        assert!(output.contains("fn main() {}"));
407    }
408
409    #[test]
410    fn test_json_formatter_file() {
411        let formatter = JsonFormatter;
412        let entry = make_entry("src/main.rs");
413        let output = formatter.format_file(&entry, "fn main() {}");
414
415        // Parse as JSON to verify validity
416        let parsed: serde_json::Value = serde_json::from_str(&output).expect("Invalid JSON");
417        assert_eq!(parsed["name"], "main.rs");
418        assert_eq!(parsed["path"], "/src/main.rs");
419        assert_eq!(parsed["content"], "fn main() {}");
420    }
421
422    #[test]
423    fn test_json_formatter_escapes_special_chars() {
424        let formatter = JsonFormatter;
425        let entry = make_entry("src/test.rs");
426        // Content with quotes, backslashes, newlines, and angle brackets
427        let content = r#"let s = "hello\nworld"; // <test>"#;
428        let output = formatter.format_file(&entry, content);
429
430        // Parse as JSON to verify validity (will fail if escaping is broken)
431        let parsed: serde_json::Value = serde_json::from_str(&output).expect("Invalid JSON");
432        assert_eq!(parsed["content"], content);
433    }
434
435    #[test]
436    fn test_json_formatter_wrap_with_tree() {
437        let formatter = JsonFormatter;
438        let entry = make_entry("src/main.rs");
439        let file_block = formatter.format_file(&entry, "fn main() {}");
440        let tree = formatter.format_tree("src/\n  main.rs");
441        let output = formatter.wrap(Some(&tree), &file_block);
442
443        // Parse as JSON to verify validity
444        let parsed: serde_json::Value = serde_json::from_str(&output).expect("Invalid JSON");
445        assert!(parsed["tree"].is_string());
446        assert!(parsed["files"].is_array());
447        assert_eq!(parsed["files"].as_array().unwrap().len(), 1);
448    }
449
450    #[test]
451    fn test_json_formatter_wrap_without_tree() {
452        let formatter = JsonFormatter;
453        let entry = make_entry("src/main.rs");
454        let file_block = formatter.format_file(&entry, "fn main() {}");
455        let output = formatter.wrap(None, &file_block);
456
457        // Parse as JSON to verify validity
458        let parsed: serde_json::Value = serde_json::from_str(&output).expect("Invalid JSON");
459        assert!(parsed.get("tree").is_none());
460        assert!(parsed["files"].is_array());
461    }
462
463    #[test]
464    fn test_json_formatter_multiple_files() {
465        let formatter = JsonFormatter;
466        let entry1 = make_entry("src/main.rs");
467        let entry2 = make_entry("src/lib.rs");
468        let file1 = formatter.format_file(&entry1, "fn main() {}");
469        let file2 = formatter.format_file(&entry2, "pub mod test;");
470        // For wrap() (non-streaming), use comma separator
471        let files_block = format!("{},{}", file1, file2);
472        let output = formatter.wrap(None, &files_block);
473
474        // Parse as JSON to verify validity
475        let parsed: serde_json::Value = serde_json::from_str(&output).expect("Invalid JSON");
476        assert_eq!(parsed["files"].as_array().unwrap().len(), 2);
477        assert_eq!(parsed["files"][0]["name"], "main.rs");
478        assert_eq!(parsed["files"][1]["name"], "lib.rs");
479    }
480
481    #[test]
482    fn test_json_formatter_streaming() {
483        let formatter = JsonFormatter;
484        let entry1 = make_entry("src/main.rs");
485        let entry2 = make_entry("src/lib.rs");
486        let file1 = formatter.format_file(&entry1, "fn main() {}");
487        let file2 = formatter.format_file(&entry2, "pub mod test;");
488
489        // Streaming uses newline separator (NDJSON format)
490        let separator = formatter.separator();
491        assert_eq!(separator, "\n");
492
493        // Each line should be valid JSON
494        let parsed1: serde_json::Value = serde_json::from_str(&file1).expect("Invalid JSON");
495        let parsed2: serde_json::Value = serde_json::from_str(&file2).expect("Invalid JSON");
496        assert_eq!(parsed1["name"], "main.rs");
497        assert_eq!(parsed2["name"], "lib.rs");
498    }
499}