Skip to main content

ctx/
formatter.rs

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