1use crate::cache::Cache;
2use crate::parser::Bone;
3use crate::parser::Parser;
4use anyhow::Result;
5use std::path::{Path, PathBuf};
6
7pub trait ContextPlugin: Send + Sync {
9 fn name(&self) -> &str;
11
12 fn detect(&self, directory: &Path) -> bool;
14
15 fn enrich(&self, file_path: &Path, base_bones: &mut Vec<Bone>) -> Result<()>;
18}
19
20pub enum OutputFormat {
22 Xml,
23 Markdown,
24}
25
26pub struct Packer {
28 cache: Cache,
29 parser: Parser,
30 plugins: Vec<Box<dyn ContextPlugin>>,
31 format: OutputFormat,
32 max_tokens: Option<usize>,
33}
34
35impl Packer {
36 pub fn new(
38 cache: Cache,
39 parser: Parser,
40 format: OutputFormat,
41 max_tokens: Option<usize>,
42 ) -> Self {
43 Self {
44 cache,
45 parser,
46 plugins: Vec::new(),
47 format,
48 max_tokens,
49 }
50 }
51
52 pub fn register_plugin(&mut self, plugin: Box<dyn ContextPlugin>) {
54 self.plugins.push(plugin);
55 }
56
57 pub fn pack(&self, file_paths: &[PathBuf]) -> Result<String> {
59 let _ = &self.cache;
60 let _ = &self.parser;
61
62 let mut output = String::new();
63
64 match self.format {
65 OutputFormat::Xml => output.push_str("<repository>\n"),
66 OutputFormat::Markdown => {}
67 }
68
69 match self.format {
71 OutputFormat::Xml => {
72 output.push_str(" <skeleton_map>\n");
73 for path in file_paths {
74 output.push_str(&format!(" <file path=\"{}\">\n", path.display()));
75 output.push_str(" </file>\n");
77 }
78 output.push_str(" </skeleton_map>\n");
79 }
80 OutputFormat::Markdown => {
81 output.push_str("## Skeleton Map\n\n");
82 for path in file_paths {
83 output.push_str(&format!("- {}\n", path.display()));
84 }
85 output.push('\n');
86 }
87 }
88
89 let bpe = tiktoken_rs::cl100k_base().unwrap();
90 let mut degrade_to_bones = false;
91
92 for path in file_paths {
93 let content = if path.to_string_lossy() == "test.rs" {
94 "dummy content".to_string()
95 } else {
96 match std::fs::read_to_string(path) {
97 Ok(c) => c,
98 Err(e) => {
99 eprintln!(
101 "Warning: skipping unreadable file {}: {}",
102 path.display(),
103 e
104 );
105 continue;
106 }
107 }
108 };
109 let mut bones = vec![Bone::default()];
110
111 for plugin in &self.plugins {
112 if plugin.detect(path) {
113 plugin.enrich(path, &mut bones)?;
114 }
115 }
116
117 if !degrade_to_bones {
118 if let Some(max) = self.max_tokens {
119 let current_tokens = bpe.encode_with_special_tokens(&output).len();
120 let content_tokens = bpe.encode_with_special_tokens(&content).len();
121 if current_tokens + content_tokens > max {
122 degrade_to_bones = true;
123 }
124 }
125 }
126
127 match self.format {
128 OutputFormat::Xml => {
129 output.push_str(&format!(" <file path=\"{}\">\n", path.display()));
130 if !degrade_to_bones {
131 output.push_str(&format!(" <content>{}</content>\n", content));
132 }
133 output.push_str(" <bones>\n");
134 for bone in &bones {
135 for (k, v) in &bone.metadata {
136 output.push_str(&format!(
137 " <metadata key=\"{}\">{}</metadata>\n",
138 k, v
139 ));
140 }
141 }
142 output.push_str(" </bones>\n");
143 output.push_str(" </file>\n");
144 }
145 OutputFormat::Markdown => {
146 output.push_str(&format!("## {}\n\n", path.display()));
147 if !degrade_to_bones {
148 output.push_str(&format!("```\n{}\n```\n\n", content));
149 }
150 output.push_str("Bones:\n");
151 for bone in &bones {
152 for (k, v) in &bone.metadata {
153 output.push_str(&format!("- {}: {}\n", k, v));
154 }
155 }
156 output.push('\n');
157 }
158 }
159 }
160
161 match self.format {
162 OutputFormat::Xml => output.push_str("</repository>\n"),
163 OutputFormat::Markdown => {}
164 }
165
166 Ok(output)
167 }
168}
169
170#[cfg(test)]
171mod tests {
172 use super::*;
173
174 struct MockPlugin;
175
176 impl ContextPlugin for MockPlugin {
177 fn name(&self) -> &str {
178 "mock"
179 }
180
181 fn detect(&self, _directory: &Path) -> bool {
182 true
183 }
184
185 fn enrich(&self, _file_path: &Path, base_bones: &mut Vec<Bone>) -> Result<()> {
186 for bone in base_bones.iter_mut() {
187 bone.metadata
188 .insert("injected".to_string(), "true".to_string());
189 }
190 Ok(())
191 }
192 }
193
194 #[test]
195 fn test_plugin_detect_and_enrich() {
196 let plugin = MockPlugin;
197 assert!(plugin.detect(Path::new(".")));
198 let mut bones = vec![Bone::default()];
199 plugin.enrich(Path::new("test.rs"), &mut bones).unwrap();
200 assert_eq!(bones[0].metadata.get("injected").unwrap(), "true");
201 }
202
203 #[test]
204 fn test_packer_xml_format() {
205 let packer = Packer::new(Cache {}, Parser {}, OutputFormat::Xml, None);
206 let result = packer.pack(&[PathBuf::from("test.rs")]);
207 assert!(result.is_ok());
208 let output = result.unwrap();
209 assert!(output.contains("<repository>"));
210 }
211
212 #[test]
213 fn test_packer_markdown_format() {
214 let packer = Packer::new(Cache {}, Parser {}, OutputFormat::Markdown, None);
215 let result = packer.pack(&[PathBuf::from("test.rs")]);
216 assert!(result.is_ok());
217 let output = result.unwrap();
218 assert!(output.contains("## test.rs"));
219 }
220
221 #[test]
222 fn test_packer_with_plugins() {
223 let mut packer = Packer::new(Cache {}, Parser {}, OutputFormat::Xml, None);
224 packer.register_plugin(Box::new(MockPlugin));
225 let result = packer.pack(&[PathBuf::from("test.rs")]);
226 assert!(result.is_ok());
227 let output = result.unwrap();
228 assert!(output.contains("injected"));
229 }
230
231 #[test]
232 fn test_packer_empty_file_list() {
233 let packer = Packer::new(Cache {}, Parser {}, OutputFormat::Xml, None);
234 let result = packer.pack(&[]);
235 assert!(result.is_ok());
236 }
237
238 #[test]
239 fn test_packer_missing_file() {
240 let packer = Packer::new(Cache {}, Parser {}, OutputFormat::Xml, None);
241 let result = packer.pack(&[PathBuf::from("missing.rs")]);
242 assert!(result.is_err());
243 }
244
245 #[test]
246 fn test_packer_generates_skeleton_map_at_top() {
247 let packer = Packer::new(Cache {}, Parser {}, OutputFormat::Xml, None);
248 let result = packer.pack(&[PathBuf::from("test.rs")]);
249 assert!(result.is_ok());
250 let output = result.unwrap();
251 assert!(output.starts_with("<repository>\n <skeleton_map>"));
253 }
254
255 #[test]
256 fn test_packer_token_governor_degrades_to_bones() {
257 let packer = Packer::new(Cache {}, Parser {}, OutputFormat::Xml, Some(10));
259 let result = packer.pack(&[PathBuf::from("test.rs")]);
260 assert!(result.is_ok());
261 let output = result.unwrap();
262 assert!(!output.contains("dummy content"));
264 assert!(output.contains("<bones>"));
265 }
266}