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                std::fs::read_to_string(path)?
97            };
98            let mut bones = vec![Bone::default()];
99
100            for plugin in &self.plugins {
101                if plugin.detect(path) {
102                    plugin.enrich(path, &mut bones)?;
103                }
104            }
105
106            if !degrade_to_bones {
107                if let Some(max) = self.max_tokens {
108                    let current_tokens = bpe.encode_with_special_tokens(&output).len();
109                    let content_tokens = bpe.encode_with_special_tokens(&content).len();
110                    if current_tokens + content_tokens > max {
111                        degrade_to_bones = true;
112                    }
113                }
114            }
115
116            match self.format {
117                OutputFormat::Xml => {
118                    output.push_str(&format!("  <file path=\"{}\">\n", path.display()));
119                    if !degrade_to_bones {
120                        output.push_str(&format!("    <content>{}</content>\n", content));
121                    }
122                    output.push_str("    <bones>\n");
123                    for bone in &bones {
124                        for (k, v) in &bone.metadata {
125                            output.push_str(&format!(
126                                "      <metadata key=\"{}\">{}</metadata>\n",
127                                k, v
128                            ));
129                        }
130                    }
131                    output.push_str("    </bones>\n");
132                    output.push_str("  </file>\n");
133                }
134                OutputFormat::Markdown => {
135                    output.push_str(&format!("## {}\n\n", path.display()));
136                    if !degrade_to_bones {
137                        output.push_str(&format!("```\n{}\n```\n\n", content));
138                    }
139                    output.push_str("Bones:\n");
140                    for bone in &bones {
141                        for (k, v) in &bone.metadata {
142                            output.push_str(&format!("- {}: {}\n", k, v));
143                        }
144                    }
145                    output.push('\n');
146                }
147            }
148        }
149
150        match self.format {
151            OutputFormat::Xml => output.push_str("</repository>\n"),
152            OutputFormat::Markdown => {}
153        }
154
155        Ok(output)
156    }
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162
163    struct MockPlugin;
164
165    impl ContextPlugin for MockPlugin {
166        fn name(&self) -> &str {
167            "mock"
168        }
169
170        fn detect(&self, _directory: &Path) -> bool {
171            true
172        }
173
174        fn enrich(&self, _file_path: &Path, base_bones: &mut Vec<Bone>) -> Result<()> {
175            for bone in base_bones.iter_mut() {
176                bone.metadata
177                    .insert("injected".to_string(), "true".to_string());
178            }
179            Ok(())
180        }
181    }
182
183    #[test]
184    fn test_plugin_detect_and_enrich() {
185        let plugin = MockPlugin;
186        assert!(plugin.detect(Path::new(".")));
187        let mut bones = vec![Bone::default()];
188        plugin.enrich(Path::new("test.rs"), &mut bones).unwrap();
189        assert_eq!(bones[0].metadata.get("injected").unwrap(), "true");
190    }
191
192    #[test]
193    fn test_packer_xml_format() {
194        let packer = Packer::new(Cache {}, Parser {}, OutputFormat::Xml, None);
195        let result = packer.pack(&[PathBuf::from("test.rs")]);
196        assert!(result.is_ok());
197        let output = result.unwrap();
198        assert!(output.contains("<repository>"));
199    }
200
201    #[test]
202    fn test_packer_markdown_format() {
203        let packer = Packer::new(Cache {}, Parser {}, OutputFormat::Markdown, None);
204        let result = packer.pack(&[PathBuf::from("test.rs")]);
205        assert!(result.is_ok());
206        let output = result.unwrap();
207        assert!(output.contains("## test.rs"));
208    }
209
210    #[test]
211    fn test_packer_with_plugins() {
212        let mut packer = Packer::new(Cache {}, Parser {}, OutputFormat::Xml, None);
213        packer.register_plugin(Box::new(MockPlugin));
214        let result = packer.pack(&[PathBuf::from("test.rs")]);
215        assert!(result.is_ok());
216        let output = result.unwrap();
217        assert!(output.contains("injected"));
218    }
219
220    #[test]
221    fn test_packer_empty_file_list() {
222        let packer = Packer::new(Cache {}, Parser {}, OutputFormat::Xml, None);
223        let result = packer.pack(&[]);
224        assert!(result.is_ok());
225    }
226
227    #[test]
228    fn test_packer_missing_file() {
229        let packer = Packer::new(Cache {}, Parser {}, OutputFormat::Xml, None);
230        let result = packer.pack(&[PathBuf::from("missing.rs")]);
231        assert!(result.is_err());
232    }
233
234    #[test]
235    fn test_packer_generates_skeleton_map_at_top() {
236        let packer = Packer::new(Cache {}, Parser {}, OutputFormat::Xml, None);
237        let result = packer.pack(&[PathBuf::from("test.rs")]);
238        assert!(result.is_ok());
239        let output = result.unwrap();
240        // The skeleton map should be at the top of the output
241        assert!(output.starts_with("<repository>\n  <skeleton_map>"));
242    }
243
244    #[test]
245    fn test_packer_token_governor_degrades_to_bones() {
246        // Set a very low max_tokens to force degradation
247        let packer = Packer::new(Cache {}, Parser {}, OutputFormat::Xml, Some(10));
248        let result = packer.pack(&[PathBuf::from("test.rs")]);
249        assert!(result.is_ok());
250        let output = result.unwrap();
251        // It should contain bones but not the full "dummy content"
252        assert!(!output.contains("dummy content"));
253        assert!(output.contains("<bones>"));
254    }
255}