Skip to main content

matrixcode_core/
overview.rs

1//! Project overview generation and caching.
2//!
3//! The `/init` command generates a project overview file using AI analysis.
4//! The overview captures the project architecture, key patterns, and development guidance.
5//!
6//! The overview file is stored at `MATRIX.md` in the project root.
7
8use std::path::{Path, PathBuf};
9use std::fs;
10use anyhow::{Context, Result};
11use crate::prompt::{build_overview_prompt, OverviewContext};
12use crate::providers::{ChatRequest, Message, MessageContent, Provider, Role};
13
14// =============================================================================
15// Configuration Constants
16// =============================================================================
17
18/// Default filename for the cached project overview.
19pub const OVERVIEW_FILENAME: &str = "MATRIX.md";
20/// Directory name for matrixcode metadata.
21pub const MATRIXCODE_DIR: &str = ".matrix";
22
23// --- Token and content limits ---
24
25/// Maximum output tokens for AI overview generation.
26const MAX_OUTPUT_TOKENS: u32 = 8192;
27
28/// Maximum characters for config file content.
29const CONFIG_FILE_MAX_CHARS: usize = 2000;
30
31/// Maximum characters for README content.
32const README_MAX_CHARS: usize = 1000;
33
34/// Maximum characters for key source file content.
35const SOURCE_FILE_MAX_CHARS: usize = 3000;
36
37/// Maximum characters for module file content.
38const MODULE_FILE_MAX_CHARS: usize = 2000;
39
40// --- Directory structure limits ---
41
42/// Maximum depth for directory tree traversal.
43const DIRECTORY_MAX_DEPTH: usize = 3;
44
45/// Maximum items to show at root level.
46const DIRECTORY_ROOT_MAX_ITEMS: usize = 15;
47
48/// Maximum items to show at non-root levels.
49const DIRECTORY_OTHER_MAX_ITEMS: usize = 10;
50
51// --- Common file names ---
52
53/// Default project name when root directory name cannot be determined.
54const DEFAULT_PROJECT_NAME: &str = "project";
55
56/// README filename to look for.
57const README_FILENAME: &str = "README.md";
58
59/// Source directory name for many project types.
60pub const SRC_DIR: &str = "src";
61
62/// Rust module file name.
63const RUST_MOD_FILE: &str = "mod.rs";
64
65/// Rust library entry file.
66const RUST_LIB_FILE: &str = "lib.rs";
67
68// --- Project type configuration ---
69
70/// Configuration for a project type, including detection and key files.
71pub struct ProjectTypeConfig {
72    /// Human-readable type name.
73    pub type_name: &'static str,
74    /// Files whose presence indicates this project type (checked in order).
75    pub detect_files: &'static [&'static str],
76    /// Key source file paths relative to project root.
77    pub key_source_files: &'static [&'static str],
78}
79
80/// All supported project type configurations.
81pub const PROJECT_TYPE_CONFIGS: &[ProjectTypeConfig] = &[
82    ProjectTypeConfig {
83        type_name: "Rust",
84        detect_files: &["Cargo.toml"],
85        key_source_files: &["src/main.rs", "src/agent.rs"],
86    },
87    ProjectTypeConfig {
88        type_name: "Go",
89        detect_files: &["go.mod"],
90        key_source_files: &["main.go", "cmd/main.go"],
91    },
92    ProjectTypeConfig {
93        type_name: "Node.js/TypeScript",
94        detect_files: &["package.json"],
95        key_source_files: &[
96            "src/index.ts", "src/index.js",
97            "src/main.ts", "src/main.js",
98            "src/app.ts", "src/app.js",
99        ],
100    },
101    ProjectTypeConfig {
102        type_name: "Python",
103        detect_files: &["pyproject.toml", "requirements.txt"],
104        key_source_files: &["main.py", "app.py", "__init__.py"],
105    },
106    ProjectTypeConfig {
107        type_name: "Java (Maven)",
108        detect_files: &["pom.xml"],
109        key_source_files: &[],
110    },
111    ProjectTypeConfig {
112        type_name: "Java (Gradle)",
113        detect_files: &["build.gradle"],
114        key_source_files: &[],
115    },
116    ProjectTypeConfig {
117        type_name: "C/C++ (Make)",
118        detect_files: &["Makefile"],
119        key_source_files: &[],
120    },
121];
122
123/// Unknown project type name.
124const PROJECT_TYPE_UNKNOWN: &str = "Unknown";
125
126// --- Configuration file names to scan ---
127
128const CONFIG_FILENAMES: &[&str] = &[
129    "Cargo.toml",
130    "package.json",
131    "go.mod",
132    "pyproject.toml",
133    "requirements.txt",
134    "pom.xml",
135    "build.gradle",
136    "Makefile",
137    "docker-compose.yml",
138    "Dockerfile",
139    "tsconfig.json",
140    "vite.config.ts",
141    "vite.config.js",
142    "next.config.js",
143    "nuxt.config.ts",
144    "tailwind.config.js",
145    "tailwind.config.ts",
146    ".env.example",
147];
148
149/// Project overview containing the generated summary.
150#[derive(Debug, Clone)]
151pub struct ProjectOverview {
152    /// The rendered markdown content.
153    pub content: String,
154    /// Path to the overview file (for cache invalidation info).
155    pub path: PathBuf,
156}
157
158impl ProjectOverview {
159    /// Load the overview from the project root if it exists.
160    /// Returns `None` if the file doesn't exist.
161    pub fn load(project_root: &Path) -> Result<Option<Self>> {
162        let path = overview_path(project_root);
163        if !path.exists() {
164            return Ok(None);
165        }
166        let content = fs::read_to_string(&path)
167            .with_context(|| format!("reading overview file {}", path.display()))?;
168        Ok(Some(Self { content, path }))
169    }
170
171    /// Generate and save a new overview using AI analysis.
172    /// This method collects project files and sends them to the AI for analysis.
173    pub async fn generate_with_ai(project_root: &Path, provider: &dyn Provider) -> Result<Self> {
174        let project_name = project_root
175            .file_name()
176            .and_then(|n| n.to_str())
177            .unwrap_or(DEFAULT_PROJECT_NAME);
178        
179        // Collect project context
180        let context = collect_project_context(project_root)?;
181        
182        // Build the AI prompt
183        let prompt = build_overview_prompt(&OverviewContext {
184            project_name: project_name.to_string(),
185            project_type: context.project_type.to_string(),
186            directory_structure: context.directory_structure.clone(),
187            config_files: context.config_files.clone(),
188            readme: context.readme.clone(),
189            source_files: context.source_files.clone(),
190        });
191        
192        // Call AI API
193        let request = ChatRequest {
194            messages: vec![Message {
195                role: Role::User,
196                content: MessageContent::Text(prompt),
197            }],
198            tools: vec![],
199            system: None,
200            think: false,
201            max_tokens: MAX_OUTPUT_TOKENS,
202            server_tools: vec![],
203            enable_caching: false, // No caching for overview generation
204        };
205        
206        let response = provider.chat(request).await
207            .with_context(|| "calling AI for overview generation")?;
208        
209        // Extract content from response
210        let content = extract_response_content(&response);
211        
212        // Save to file
213        let path = overview_path(project_root);
214        fs::write(&path, &content)
215            .with_context(|| format!("writing overview file {}", path.display()))?;
216        
217        Ok(Self { content, path })
218    }
219
220    /// Delete the overview file if it exists.
221    pub fn clear(project_root: &Path) -> Result<()> {
222        let path = overview_path(project_root);
223        if path.exists() {
224            fs::remove_file(&path)
225                .with_context(|| format!("removing overview file {}", path.display()))?;
226        }
227        Ok(())
228    }
229
230    /// Check if an overview exists for the project.
231    pub fn exists(project_root: &Path) -> bool {
232        overview_path(project_root).exists()
233    }
234
235    /// Get the path to the overview file.
236    pub fn path(project_root: &Path) -> PathBuf {
237        overview_path(project_root)
238    }
239}
240
241/// Get the path to the overview file (directly in project root).
242fn overview_path(project_root: &Path) -> PathBuf {
243    project_root.join(OVERVIEW_FILENAME)
244}
245
246/// Patterns to ignore when scanning the project.
247const IGNORE_PATTERNS: &[&str] = &[
248    // Version control
249    ".git",
250    ".svn",
251    ".hg",
252    // Dependencies
253    "node_modules",
254    "vendor",
255    // Build outputs
256    "target",
257    "target-test",
258    "build",
259    "dist",
260    "out",
261    "bin",
262    "obj",
263    ".cargo",
264    // IDE and editor
265    ".idea",
266    ".vscode",
267    ".vs",
268    ".claude",
269    ".matrix",
270    // Cache and temp
271    ".cache",
272    "__pycache__",
273    "*.pyc",
274    ".DS_Store",
275    "Thumbs.db",
276    // Lock files (usually large and not informative)
277    "Cargo.lock",
278    "package-lock.json",
279    "yarn.lock",
280    "pnpm-lock.yaml",
281    // Generated files
282    "*.generated.*",
283    "swagger.json",
284    "swagger.yaml",
285];
286
287/// Check if a path component should be ignored.
288pub fn should_ignore(name: &str) -> bool {
289    if IGNORE_PATTERNS.contains(&name) {
290        return true;
291    }
292    for pattern in IGNORE_PATTERNS {
293        if pattern.starts_with("*.") {
294            let suffix = &pattern[1..];
295            if name.ends_with(suffix) {
296                return true;
297            }
298        }
299    }
300    false
301}
302
303/// Project context collected for AI analysis.
304struct ProjectContext {
305    /// Configuration file contents (Cargo.toml, package.json, etc.)
306    config_files: Vec<(String, String)>,
307    /// README content (first part)
308    readme: Option<String>,
309    /// Directory structure summary
310    directory_structure: String,
311    /// Key source files content (limited)
312    source_files: Vec<(String, String)>,
313    /// Project type detected
314    project_type: &'static str,
315}
316
317/// Collect project context for AI analysis.
318fn collect_project_context(project_root: &Path) -> Result<ProjectContext> {
319    // Detect project type
320    let project_type = detect_project_type(project_root);
321    
322    // Collect config files
323    let config_files = collect_config_files(project_root)?;
324    
325    // Get README
326    let readme = read_readme(project_root)?;
327    
328    // Build directory structure
329    let directory_structure = build_directory_structure(project_root)?;
330    
331    // Collect key source files
332    let source_files = collect_key_source_files(project_root, project_type)?;
333    
334    Ok(ProjectContext {
335        config_files,
336        readme,
337        directory_structure,
338        source_files,
339        project_type,
340    })
341}
342
343/// Detect project type from configuration files.
344pub fn detect_project_type(project_root: &Path) -> &'static str {
345    for config in PROJECT_TYPE_CONFIGS {
346        for detect_file in config.detect_files {
347            if project_root.join(detect_file).exists() {
348                return config.type_name;
349            }
350        }
351    }
352    PROJECT_TYPE_UNKNOWN
353}
354
355/// Collect configuration files content.
356fn collect_config_files(project_root: &Path) -> Result<Vec<(String, String)>> {
357    let mut files = Vec::new();
358    for filename in CONFIG_FILENAMES {
359        let path = project_root.join(filename);
360        if path.exists() {
361            let content = fs::read_to_string(&path)
362                .with_context(|| format!("reading {}", filename))?;
363            let truncated = truncate_content(&content, CONFIG_FILE_MAX_CHARS);
364            files.push((filename.to_string(), truncated));
365        }
366    }
367    
368    Ok(files)
369}
370
371/// Read README.md (first part).
372fn read_readme(project_root: &Path) -> Result<Option<String>> {
373    let readme_path = project_root.join(README_FILENAME);
374    if !readme_path.exists() {
375        return Ok(None);
376    }
377    
378    let content = fs::read_to_string(&readme_path)
379        .with_context(|| format!("reading {}", README_FILENAME))?;
380    
381    Ok(Some(truncate_content(&content, README_MAX_CHARS)))
382}
383
384/// Build directory structure string.
385fn build_directory_structure(project_root: &Path) -> Result<String> {
386    let mut result = String::new();
387    result.push_str(&format!("{}/\n", project_root.file_name().and_then(|n| n.to_str()).unwrap_or(DEFAULT_PROJECT_NAME)));
388    
389    build_tree_recursive(project_root, 0, DIRECTORY_MAX_DEPTH, &mut result)?;
390    
391    Ok(result)
392}
393
394/// Build directory tree recursively.
395fn build_tree_recursive(dir: &Path, depth: usize, max_depth: usize, result: &mut String) -> Result<()> {
396    if depth > max_depth {
397        result.push_str(&format!("{}  ...\n", "  ".repeat(depth)));
398        return Ok(());
399    }
400    
401    let entries = fs::read_dir(dir).ok();
402    if entries.is_none() {
403        return Ok(());
404    }
405    
406    let mut dirs: Vec<String> = Vec::new();
407    let mut files: Vec<String> = Vec::new();
408    
409    for entry in entries.unwrap().flatten() {
410        let name = entry.file_name().to_string_lossy().into_owned();
411        if should_ignore(&name) {
412            continue;
413        }
414        if entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
415            dirs.push(name);
416        } else {
417            files.push(name);
418        }
419    }
420    
421    dirs.sort();
422    files.sort();
423    
424    let indent = "  ".repeat(depth);
425    let max_items = if depth == 0 { DIRECTORY_ROOT_MAX_ITEMS } else { DIRECTORY_OTHER_MAX_ITEMS };
426    
427    let mut count = 0;
428    for d in &dirs {
429        if count >= max_items {
430            result.push_str(&format!("{}  ... ({} more dirs)\n", indent, dirs.len() - count));
431            break;
432        }
433        result.push_str(&format!("{}  {}/\n", indent, d));
434        build_tree_recursive(&dir.join(d), depth + 1, max_depth, result)?;
435        count += 1;
436    }
437    
438    for f in files.iter().take(max_items - count) {
439        result.push_str(&format!("{}  {}\n", indent, f));
440    }
441    
442    if files.len() > max_items - count {
443        result.push_str(&format!("{}  ... ({} more files)\n", indent, files.len() - (max_items - count)));
444    }
445    
446    Ok(())
447}
448
449/// Collect key source files for analysis.
450fn collect_key_source_files(project_root: &Path, project_type: &str) -> Result<Vec<(String, String)>> {
451    let mut files = Vec::new();
452    
453    // Find the matching project type config
454    let config = PROJECT_TYPE_CONFIGS.iter()
455        .find(|c| c.type_name == project_type);
456    
457    // Collect key source files from config
458    if let Some(config) = config {
459        for path_str in config.key_source_files {
460            let path = project_root.join(path_str);
461            if path.exists() {
462                let content = fs::read_to_string(&path).ok();
463                if let Some(content) = content {
464                    files.push((path_str.to_string(), truncate_content(&content, SOURCE_FILE_MAX_CHARS)));
465                }
466            }
467        }
468    }
469    
470    // Special handling for Rust: collect lib.rs and module files
471    if project_type == "Rust" {
472        // Collect lib.rs
473        let lib_path = project_root.join(SRC_DIR).join(RUST_LIB_FILE);
474        if lib_path.exists() {
475            let lib_relative = format!("{}/{}", SRC_DIR, RUST_LIB_FILE);
476            let content = fs::read_to_string(&lib_path).ok();
477            if let Some(content) = content {
478                files.push((lib_relative, truncate_content(&content, SOURCE_FILE_MAX_CHARS)));
479            }
480            
481            // Collect module files (mod.rs in subdirectories)
482            let src_path = project_root.join(SRC_DIR);
483            if src_path.exists() {
484                for entry in fs::read_dir(&src_path)?.flatten() {
485                    let name = entry.file_name().to_string_lossy().into_owned();
486                    if entry.file_type().map(|t| t.is_dir()).unwrap_or(false) && !should_ignore(&name) {
487                        let mod_path = src_path.join(&name).join(RUST_MOD_FILE);
488                        if mod_path.exists() {
489                            let content = fs::read_to_string(&mod_path).ok();
490                            if let Some(content) = content {
491                                let mod_relative = format!("{}/{}/{}", SRC_DIR, name, RUST_MOD_FILE);
492                                files.push((mod_relative, truncate_content(&content, MODULE_FILE_MAX_CHARS)));
493                            }
494                        }
495                    }
496                }
497            }
498        }
499    }
500    
501    Ok(files)
502}
503
504/// Truncate content to a maximum length, respecting char boundaries.
505pub fn truncate_content(content: &str, max_len: usize) -> String {
506    if content.len() <= max_len {
507        content.to_string()
508    } else {
509        // Find valid char boundary
510        let mut end = max_len;
511        while end > 0 && !content.is_char_boundary(end) {
512            end -= 1;
513        }
514        let mut truncated = content[..end].to_string();
515        truncated.push_str("\n... (truncated)");
516        truncated
517    }
518}
519
520/// Extract content from AI response.
521fn extract_response_content(response: &crate::providers::ChatResponse) -> String {
522    let mut content = String::new();
523    for block in &response.content {
524        if let crate::providers::ContentBlock::Text { text } = block {
525            content.push_str(text);
526        }
527    }
528    content
529}
530
531#[cfg(test)]
532mod tests {
533    use super::*;
534    
535    #[test]
536    fn truncate_content_respects_char_boundary() {
537        // Chinese text with multibyte characters
538        let text = "这是一个包含中文字符的测试文本,用于验证截断功能是否正确处理字符边界问题。";
539        
540        // Truncate at a position that would fall inside a multibyte character
541        let truncated = truncate_content(text, 50);
542        
543        // Should not panic and should end with truncated marker
544        assert!(truncated.contains("... (truncated)"));
545        // String in Rust is always valid UTF-8, no need to check
546    }
547    
548    #[test]
549    fn truncate_content_preserves_short_text() {
550        let short = "hello world";
551        let result = truncate_content(short, 100);
552        assert_eq!(result, short);
553    }
554    
555    #[test]
556    fn truncate_content_exact_boundary() {
557        // ASCII text - every byte is a char boundary
558        let text = "abcdefghijklmnopqrstuvwxyz";
559        let truncated = truncate_content(text, 10);
560        assert_eq!(truncated, "abcdefghij\n... (truncated)");
561    }
562    
563    #[test]
564    fn truncate_content_multibyte_edge() {
565        // Text ending exactly at a multibyte char
566        let text = "你好世界hello";
567        let truncated = truncate_content(text, 12); // "你好世界" = 12 bytes
568        assert!(truncated.starts_with("你好世界"));
569    }
570}