1use 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
14pub const OVERVIEW_FILENAME: &str = "MATRIX.md";
20pub const MATRIXCODE_DIR: &str = ".matrix";
22
23const MAX_OUTPUT_TOKENS: u32 = 8192;
27
28const CONFIG_FILE_MAX_CHARS: usize = 2000;
30
31const README_MAX_CHARS: usize = 1000;
33
34const SOURCE_FILE_MAX_CHARS: usize = 3000;
36
37const MODULE_FILE_MAX_CHARS: usize = 2000;
39
40const DIRECTORY_MAX_DEPTH: usize = 3;
44
45const DIRECTORY_ROOT_MAX_ITEMS: usize = 15;
47
48const DIRECTORY_OTHER_MAX_ITEMS: usize = 10;
50
51const DEFAULT_PROJECT_NAME: &str = "project";
55
56const README_FILENAME: &str = "README.md";
58
59pub const SRC_DIR: &str = "src";
61
62const RUST_MOD_FILE: &str = "mod.rs";
64
65const RUST_LIB_FILE: &str = "lib.rs";
67
68pub struct ProjectTypeConfig {
72 pub type_name: &'static str,
74 pub detect_files: &'static [&'static str],
76 pub key_source_files: &'static [&'static str],
78}
79
80pub 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
123const PROJECT_TYPE_UNKNOWN: &str = "Unknown";
125
126const 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#[derive(Debug, Clone)]
151pub struct ProjectOverview {
152 pub content: String,
154 pub path: PathBuf,
156}
157
158impl ProjectOverview {
159 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 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 let context = collect_project_context(project_root)?;
181
182 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 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, };
205
206 let response = provider.chat(request).await
207 .with_context(|| "calling AI for overview generation")?;
208
209 let content = extract_response_content(&response);
211
212 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 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 pub fn exists(project_root: &Path) -> bool {
232 overview_path(project_root).exists()
233 }
234
235 pub fn path(project_root: &Path) -> PathBuf {
237 overview_path(project_root)
238 }
239}
240
241fn overview_path(project_root: &Path) -> PathBuf {
243 project_root.join(OVERVIEW_FILENAME)
244}
245
246const IGNORE_PATTERNS: &[&str] = &[
248 ".git",
250 ".svn",
251 ".hg",
252 "node_modules",
254 "vendor",
255 "target",
257 "target-test",
258 "build",
259 "dist",
260 "out",
261 "bin",
262 "obj",
263 ".cargo",
264 ".idea",
266 ".vscode",
267 ".vs",
268 ".claude",
269 ".matrix",
270 ".cache",
272 "__pycache__",
273 "*.pyc",
274 ".DS_Store",
275 "Thumbs.db",
276 "Cargo.lock",
278 "package-lock.json",
279 "yarn.lock",
280 "pnpm-lock.yaml",
281 "*.generated.*",
283 "swagger.json",
284 "swagger.yaml",
285];
286
287pub 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
303struct ProjectContext {
305 config_files: Vec<(String, String)>,
307 readme: Option<String>,
309 directory_structure: String,
311 source_files: Vec<(String, String)>,
313 project_type: &'static str,
315}
316
317fn collect_project_context(project_root: &Path) -> Result<ProjectContext> {
319 let project_type = detect_project_type(project_root);
321
322 let config_files = collect_config_files(project_root)?;
324
325 let readme = read_readme(project_root)?;
327
328 let directory_structure = build_directory_structure(project_root)?;
330
331 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
343pub 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
355fn 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
371fn 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
384fn 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
394fn 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
449fn collect_key_source_files(project_root: &Path, project_type: &str) -> Result<Vec<(String, String)>> {
451 let mut files = Vec::new();
452
453 let config = PROJECT_TYPE_CONFIGS.iter()
455 .find(|c| c.type_name == project_type);
456
457 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 if project_type == "Rust" {
472 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 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
504pub fn truncate_content(content: &str, max_len: usize) -> String {
506 if content.len() <= max_len {
507 content.to_string()
508 } else {
509 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
520fn 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 let text = "这是一个包含中文字符的测试文本,用于验证截断功能是否正确处理字符边界问题。";
539
540 let truncated = truncate_content(text, 50);
542
543 assert!(truncated.contains("... (truncated)"));
545 }
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 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 let text = "你好世界hello";
567 let truncated = truncate_content(text, 12); assert!(truncated.starts_with("你好世界"));
569 }
570}