Skip to main content

codebones_core/
plugin.rs

1use crate::cache::Cache;
2use crate::parser::Bone;
3use crate::parser::Parser;
4use anyhow::Result;
5use std::path::{Path, PathBuf};
6
7/// A plugin that can enrich extracted code bones with domain-specific metadata.
8pub trait ContextPlugin: Send + Sync {
9    /// The unique name of the plugin (e.g., "dbt", "openapi").
10    fn name(&self) -> &str;
11
12    /// Returns true if this plugin should be active for the given directory/workspace.
13    fn detect(&self, directory: &Path) -> bool;
14
15    /// Enriches the extracted bones for a specific file with additional metadata.
16    /// The plugin can modify the `base_bones` in place (e.g., adding JSON metadata).
17    fn enrich(&self, file_path: &Path, base_bones: &mut Vec<Bone>) -> Result<()>;
18}
19
20/// Supported output formats for the packed context.
21pub enum OutputFormat {
22    Xml,
23    Markdown,
24}
25
26/// Bundles files and their enriched bones into an AI-friendly output format.
27pub 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    /// Creates a new Packer instance.
37    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    /// Registers a context plugin.
53    pub fn register_plugin(&mut self, plugin: Box<dyn ContextPlugin>) {
54        self.plugins.push(plugin);
55    }
56
57    /// Packs the specified files into a single formatted string.
58    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        // Generate Skeleton Map
70        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                    // Bones would be listed here in a real implementation
76                    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                        // Skip unreadable files gracefully (e.g. they were deleted since indexing)
100                        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        // The skeleton map should be at the top of the output
252        assert!(output.starts_with("<repository>\n  <skeleton_map>"));
253    }
254
255    #[test]
256    fn test_packer_token_governor_degrades_to_bones() {
257        // Set a very low max_tokens to force degradation
258        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        // It should contain bones but not the full "dummy content"
263        assert!(!output.contains("dummy content"));
264        assert!(output.contains("<bones>"));
265    }
266}