Skip to main content

aacode_rs/context/
mod.rs

1// Copyright (c) 2026 xiefujin <490021684@qq.com>
2// Licensed under GPL-3.0, see LICENSE file for full license terms.
3
4//! Context management: init.md loading, large-output archiving, and a light
5//! project-structure summary injected before a task.
6//!
7//! Ported from Python `utils/context_manager.py` + `class_method_mapper.py`
8//! (structure analysis is simplified to a file/extension summary).
9
10use std::path::{Path, PathBuf};
11
12pub struct ContextManager {
13    project_path: PathBuf,
14}
15
16const DEFAULT_INIT_MD: &str = r#"# Project Guidelines
17
18## Core Rules
191. Annotate path at top of each code file: `# {relative_path}`
202. Prefer modifying existing files over creating new ones
213. All file operations must stay within the project directory
224. Dangerous commands require caution
23
24## Workflow
251. Analyze requirements first, then plan
262. Small steps, frequent testing
273. Write self-contained test functions
284. Check safety before using tools
29
30## Code Quality
31- Follow language best practices
32- Keep functions reasonably short
33- Add necessary docstrings
34- Handle errors gracefully
35------
36"#;
37
38impl ContextManager {
39    pub fn new(project_path: &Path) -> Self {
40        let cm = ContextManager {
41            project_path: project_path.to_path_buf(),
42        };
43        let _ = std::fs::create_dir_all(cm.context_dir());
44        cm
45    }
46
47    fn context_dir(&self) -> PathBuf {
48        self.project_path.join(".aacode").join("context")
49    }
50
51    /// Load init.md, creating a default one if absent. Returns its contents.
52    pub fn load_init_instructions(&self) -> String {
53        let path = self.project_path.join("init.md");
54        if let Ok(s) = std::fs::read_to_string(&path) {
55            if !s.trim().is_empty() {
56                return s;
57            }
58        }
59        // create default
60        let _ = std::fs::write(&path, DEFAULT_INIT_MD);
61        DEFAULT_INIT_MD.to_string()
62    }
63
64    /// Archive a large output to `.aacode/context/` and return its path.
65    pub fn save_large_output(&self, content: &str, name_hint: &str) -> Option<String> {
66        let dir = self.context_dir();
67        std::fs::create_dir_all(&dir).ok()?;
68        let name = format!(
69            "{}_{}.txt",
70            sanitize(name_hint),
71            uuid::Uuid::new_v4().simple()
72        );
73        let path = dir.join(name);
74        std::fs::write(&path, content).ok()?;
75        Some(path.to_string_lossy().to_string())
76    }
77
78    /// Produce a light project-structure summary: top-level entries and a
79    /// per-extension file count. Bounded output for prompt injection.
80    pub fn analyze_project_structure(&self) -> String {
81        use std::collections::BTreeMap;
82        let mut ext_counts: BTreeMap<String, usize> = BTreeMap::new();
83        let mut top_entries: Vec<String> = Vec::new();
84        let exclude = [
85            ".git",
86            ".aacode",
87            "node_modules",
88            "target",
89            "__pycache__",
90            ".venv",
91        ];
92
93        if let Ok(rd) = std::fs::read_dir(&self.project_path) {
94            for e in rd.flatten() {
95                let name = e.file_name().to_string_lossy().to_string();
96                if exclude.contains(&name.as_str()) {
97                    continue;
98                }
99                let is_dir = e.path().is_dir();
100                top_entries.push(if is_dir { format!("{name}/") } else { name });
101            }
102        }
103        top_entries.sort();
104
105        // Walk (bounded) for extension stats.
106        let mut stack = vec![self.project_path.clone()];
107        let mut visited = 0usize;
108        while let Some(dir) = stack.pop() {
109            if visited > 5000 {
110                break;
111            }
112            let rd = match std::fs::read_dir(&dir) {
113                Ok(r) => r,
114                Err(_) => continue,
115            };
116            for e in rd.flatten() {
117                let p = e.path();
118                let fname = e.file_name().to_string_lossy().to_string();
119                if exclude.contains(&fname.as_str()) {
120                    continue;
121                }
122                if p.is_dir() {
123                    stack.push(p);
124                } else {
125                    visited += 1;
126                    let ext = p
127                        .extension()
128                        .map(|s| s.to_string_lossy().to_string())
129                        .unwrap_or_else(|| "(none)".to_string());
130                    *ext_counts.entry(ext).or_insert(0) += 1;
131                }
132            }
133        }
134
135        let mut out = String::from("Project structure summary:\n\nTop-level entries:\n");
136        for e in top_entries.iter().take(50) {
137            out.push_str(&format!("  {e}\n"));
138        }
139        out.push_str("\nFile counts by extension:\n");
140        for (ext, n) in ext_counts.iter() {
141            out.push_str(&format!("  .{ext}: {n}\n"));
142        }
143        // bound size
144        if out.len() > 1500 {
145            out.truncate(1500);
146            out.push_str("\n...(truncated)");
147        }
148        out
149    }
150}
151
152fn sanitize(s: &str) -> String {
153    s.chars()
154        .map(|c| {
155            if c.is_alphanumeric() || c == '_' {
156                c
157            } else {
158                '_'
159            }
160        })
161        .collect()
162}
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167
168    fn tmp() -> PathBuf {
169        let d = std::env::temp_dir().join(format!(
170            "aacode_ctx_{}_{}",
171            std::process::id(),
172            uuid::Uuid::new_v4().simple()
173        ));
174        std::fs::create_dir_all(&d).unwrap();
175        d
176    }
177
178    #[test]
179    fn init_md_created_default() {
180        let d = tmp();
181        let cm = ContextManager::new(&d);
182        let content = cm.load_init_instructions();
183        assert!(content.contains("Project Guidelines"));
184        assert!(d.join("init.md").exists());
185    }
186
187    #[test]
188    fn init_md_existing_used() {
189        let d = tmp();
190        std::fs::write(d.join("init.md"), "# Custom\nrules").unwrap();
191        let cm = ContextManager::new(&d);
192        assert!(cm.load_init_instructions().contains("Custom"));
193    }
194
195    #[test]
196    fn large_output_archived() {
197        let d = tmp();
198        let cm = ContextManager::new(&d);
199        let path = cm
200            .save_large_output("big content", "run_shell_output")
201            .unwrap();
202        assert!(std::path::Path::new(&path).exists());
203        assert_eq!(std::fs::read_to_string(&path).unwrap(), "big content");
204    }
205
206    #[test]
207    fn structure_summary() {
208        let d = tmp();
209        std::fs::write(d.join("main.rs"), "fn main(){}").unwrap();
210        std::fs::write(d.join("lib.py"), "x=1").unwrap();
211        std::fs::create_dir_all(d.join("src")).unwrap();
212        let cm = ContextManager::new(&d);
213        let s = cm.analyze_project_structure();
214        assert!(s.contains("main.rs"));
215        assert!(s.contains(".rs:") || s.contains("rs:"));
216    }
217
218    #[test]
219    fn large_output_sanitizes_path_hint() {
220        let d = tmp();
221        let cm = ContextManager::new(&d);
222        let path = cm.save_large_output("payload", "../../etc/passwd").unwrap();
223        let p = std::path::Path::new(&path);
224        // Must stay inside `.aacode/context` (no traversal).
225        assert!(p.starts_with(cm.context_dir()));
226        let fname = p.file_name().unwrap().to_string_lossy();
227        assert!(!fname.contains('/') && !fname.contains(".."));
228        assert!(fname.starts_with("______etc_passwd_"));
229    }
230
231    #[test]
232    fn structure_excludes_heavy_dirs() {
233        let d = tmp();
234        std::fs::create_dir_all(d.join("node_modules")).unwrap();
235        std::fs::write(d.join("node_modules/evil.js"), "x").unwrap();
236        std::fs::create_dir_all(d.join(".git")).unwrap();
237        std::fs::write(d.join(".git/config"), "x").unwrap();
238        std::fs::create_dir_all(d.join("src")).unwrap();
239        std::fs::write(d.join("main.rs"), "fn main(){}").unwrap();
240        let cm = ContextManager::new(&d);
241        let s = cm.analyze_project_structure();
242        assert!(s.contains("main.rs"));
243        assert!(s.contains("src/"));
244        assert!(!s.contains("node_modules"));
245        assert!(!s.contains(".git"));
246    }
247}