1use 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
15pub const OVERVIEW_FILENAME: &str = "MATRIX.md";
21pub const MATRIXCODE_DIR: &str = ".matrix";
23
24const MAX_OUTPUT_TOKENS: u32 = 8192;
28
29const CONFIG_FILE_MAX_CHARS: usize = 2000;
31
32const README_MAX_CHARS: usize = 1000;
34
35const SOURCE_FILE_MAX_CHARS: usize = 3000;
37
38const MODULE_FILE_MAX_CHARS: usize = 2000;
40
41const DIRECTORY_MAX_DEPTH: usize = 3;
45
46const DIRECTORY_ROOT_MAX_ITEMS: usize = 15;
48
49const DIRECTORY_OTHER_MAX_ITEMS: usize = 10;
51
52const DEFAULT_PROJECT_NAME: &str = "project";
56
57const README_FILENAME: &str = "README.md";
59
60pub const SRC_DIR: &str = "src";
62
63const RUST_MOD_FILE: &str = "mod.rs";
65
66const RUST_LIB_FILE: &str = "lib.rs";
68
69pub struct ProjectTypeConfig {
73 pub type_name: &'static str,
75 pub detect_files: &'static [&'static str],
77 pub key_source_files: &'static [&'static str],
79}
80
81pub 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
127const PROJECT_TYPE_UNKNOWN: &str = "Unknown";
129
130const 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#[derive(Debug, Clone)]
155pub struct ProjectOverview {
156 pub content: String,
158 pub path: PathBuf,
160}
161
162impl ProjectOverview {
163 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 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 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 let context = collect_project_context(project_root)?;
193
194 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 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, };
217
218 let response = provider
219 .chat(request)
220 .await
221 .with_context(|| "calling AI for overview generation")?;
222
223 let content = extract_response_content(&response);
225
226 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 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 pub fn exists(project_root: &Path) -> bool {
246 overview_path(project_root).exists()
247 }
248
249 pub fn path(project_root: &Path) -> PathBuf {
251 overview_path(project_root)
252 }
253}
254
255fn overview_path(project_root: &Path) -> PathBuf {
257 project_root.join(OVERVIEW_FILENAME)
258}
259
260const IGNORE_PATTERNS: &[&str] = &[
262 ".git",
264 ".svn",
265 ".hg",
266 "node_modules",
268 "vendor",
269 "target",
271 "target-test",
272 "build",
273 "dist",
274 "out",
275 "bin",
276 "obj",
277 ".cargo",
278 ".idea",
280 ".vscode",
281 ".vs",
282 ".claude",
283 ".matrix",
284 ".cache",
286 "__pycache__",
287 "*.pyc",
288 ".DS_Store",
289 "Thumbs.db",
290 "Cargo.lock",
292 "package-lock.json",
293 "yarn.lock",
294 "pnpm-lock.yaml",
295 "*.generated.*",
297 "swagger.json",
298 "swagger.yaml",
299];
300
301pub 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
317struct ProjectContext {
319 config_files: Vec<(String, String)>,
321 readme: Option<String>,
323 directory_structure: String,
325 source_files: Vec<(String, String)>,
327 project_type: &'static str,
329}
330
331fn collect_project_context(project_root: &Path) -> Result<ProjectContext> {
333 let project_type = detect_project_type(project_root);
335
336 let config_files = collect_config_files(project_root)?;
338
339 let readme = read_readme(project_root)?;
341
342 let directory_structure = build_directory_structure(project_root)?;
344
345 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
357pub 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
369fn 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
385fn 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
398fn 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
414fn 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
486fn 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 let config = PROJECT_TYPE_CONFIGS
495 .iter()
496 .find(|c| c.type_name == project_type);
497
498 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 if project_type == "Rust" {
516 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 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
557pub 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
569fn 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 let text = "这是一个包含中文字符的测试文本,用于验证截断功能是否正确处理字符边界问题。";
588
589 let truncated = truncate_content(text, 50);
591
592 assert!(truncated.contains("... (truncated)"));
594 }
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 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 let text = "你好世界hello";
616 let truncated = truncate_content(text, 12); assert!(truncated.starts_with("你好世界"));
618 }
619}