1use std::path::Path;
2
3use crate::walker::FileEntry;
4
5pub trait Formatter {
7 fn format_tree(&self, tree: &str) -> String;
9
10 fn format_file(&self, entry: &FileEntry, content: &str) -> String;
12
13 fn wrap(&self, tree_block: Option<&str>, files_block: &str) -> String;
15
16 fn stream_start(&self, tree_block: Option<&str>) -> String;
18
19 fn stream_end(&self) -> String;
21
22 fn separator(&self) -> &'static str;
24}
25
26pub struct XmlFormatter;
28
29impl XmlFormatter {
30 fn escape_xml_text(s: &str) -> String {
33 s.replace('&', "&")
34 .replace('<', "<")
35 .replace('>', ">")
36 }
37
38 fn escape_xml_attr(s: &str) -> String {
41 s.replace('&', "&")
42 .replace('<', "<")
43 .replace('>', ">")
44 .replace('"', """)
45 .replace('\'', "'")
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
103pub 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
145pub 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
181pub struct JsonFormatter;
196
197impl JsonFormatter {
198 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 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 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 match tree_block {
232 Some(tree) => format!(
233 r#"{{"tree":{},"files":[{}]}}"#,
234 tree, files_block
236 ),
237 None => format!(r#"{{"files":[{}]}}"#, files_block),
238 }
239 }
240
241 fn stream_start(&self, tree_block: Option<&str>) -> String {
242 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 String::new()
253 }
254
255 fn separator(&self) -> &'static str {
256 "\n"
258 }
259}
260
261fn 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
273pub enum OutputFormat {
274 #[default]
276 Xml,
277 Markdown,
279 Plain,
281 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
300pub 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 let content = r#"if x < 10 && y > 5 { println!("<tag>"); }"#;
340 let output = formatter.format_file(&entry, content);
341
342 assert!(output.contains("x < 10"));
344 assert!(output.contains("&&"));
345 assert!(output.contains("y > 5"));
346 assert!(output.contains("<tag>"));
347 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 let entry = make_entry("src/test&file.rs");
357 let output = formatter.format_file(&entry, "content");
358
359 assert!(output.contains("name=\"test&file.rs\""));
361 assert!(output.contains("path=\"/src/test&file.rs\""));
362 }
363
364 #[test]
365 fn test_xml_formatter_escapes_quotes_in_attrs() {
366 let escaped = XmlFormatter::escape_xml_attr(r#"file"name'test"#);
368 assert_eq!(escaped, "file"name'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("<generated>"));
378 assert!(output.contains("test&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 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 let content = r#"let s = "hello\nworld"; // <test>"#;
421 let output = formatter.format_file(&entry, content);
422
423 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 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 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 let files_block = format!("{},{}", file1, file2);
465 let output = formatter.wrap(None, &files_block);
466
467 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 let separator = formatter.separator();
484 assert_eq!(separator, "\n");
485
486 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}