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