use std::path::{Path, PathBuf};
use std::fs;
use anyhow::{Context, Result};
use crate::prompt::{build_overview_prompt, OverviewContext};
use crate::providers::{ChatRequest, Message, MessageContent, Provider, Role};
pub const OVERVIEW_FILENAME: &str = "MATRIX.md";
pub const MATRIXCODE_DIR: &str = ".matrix";
const MAX_OUTPUT_TOKENS: u32 = 8192;
const CONFIG_FILE_MAX_CHARS: usize = 2000;
const README_MAX_CHARS: usize = 1000;
const SOURCE_FILE_MAX_CHARS: usize = 3000;
const MODULE_FILE_MAX_CHARS: usize = 2000;
const DIRECTORY_MAX_DEPTH: usize = 3;
const DIRECTORY_ROOT_MAX_ITEMS: usize = 15;
const DIRECTORY_OTHER_MAX_ITEMS: usize = 10;
const DEFAULT_PROJECT_NAME: &str = "project";
const README_FILENAME: &str = "README.md";
pub const SRC_DIR: &str = "src";
const RUST_MOD_FILE: &str = "mod.rs";
const RUST_LIB_FILE: &str = "lib.rs";
pub struct ProjectTypeConfig {
pub type_name: &'static str,
pub detect_files: &'static [&'static str],
pub key_source_files: &'static [&'static str],
}
pub const PROJECT_TYPE_CONFIGS: &[ProjectTypeConfig] = &[
ProjectTypeConfig {
type_name: "Rust",
detect_files: &["Cargo.toml"],
key_source_files: &["src/main.rs", "src/agent.rs"],
},
ProjectTypeConfig {
type_name: "Go",
detect_files: &["go.mod"],
key_source_files: &["main.go", "cmd/main.go"],
},
ProjectTypeConfig {
type_name: "Node.js/TypeScript",
detect_files: &["package.json"],
key_source_files: &[
"src/index.ts", "src/index.js",
"src/main.ts", "src/main.js",
"src/app.ts", "src/app.js",
],
},
ProjectTypeConfig {
type_name: "Python",
detect_files: &["pyproject.toml", "requirements.txt"],
key_source_files: &["main.py", "app.py", "__init__.py"],
},
ProjectTypeConfig {
type_name: "Java (Maven)",
detect_files: &["pom.xml"],
key_source_files: &[],
},
ProjectTypeConfig {
type_name: "Java (Gradle)",
detect_files: &["build.gradle"],
key_source_files: &[],
},
ProjectTypeConfig {
type_name: "C/C++ (Make)",
detect_files: &["Makefile"],
key_source_files: &[],
},
];
const PROJECT_TYPE_UNKNOWN: &str = "Unknown";
const CONFIG_FILENAMES: &[&str] = &[
"Cargo.toml",
"package.json",
"go.mod",
"pyproject.toml",
"requirements.txt",
"pom.xml",
"build.gradle",
"Makefile",
"docker-compose.yml",
"Dockerfile",
"tsconfig.json",
"vite.config.ts",
"vite.config.js",
"next.config.js",
"nuxt.config.ts",
"tailwind.config.js",
"tailwind.config.ts",
".env.example",
];
#[derive(Debug, Clone)]
pub struct ProjectOverview {
pub content: String,
pub path: PathBuf,
}
impl ProjectOverview {
pub fn load(project_root: &Path) -> Result<Option<Self>> {
let path = overview_path(project_root);
if !path.exists() {
return Ok(None);
}
let content = fs::read_to_string(&path)
.with_context(|| format!("reading overview file {}", path.display()))?;
Ok(Some(Self { content, path }))
}
pub async fn generate_with_ai(project_root: &Path, provider: &dyn Provider) -> Result<Self> {
let project_name = project_root
.file_name()
.and_then(|n| n.to_str())
.unwrap_or(DEFAULT_PROJECT_NAME);
let context = collect_project_context(project_root)?;
let prompt = build_overview_prompt(&OverviewContext {
project_name: project_name.to_string(),
project_type: context.project_type.to_string(),
directory_structure: context.directory_structure.clone(),
config_files: context.config_files.clone(),
readme: context.readme.clone(),
source_files: context.source_files.clone(),
});
let request = ChatRequest {
messages: vec![Message {
role: Role::User,
content: MessageContent::Text(prompt),
}],
tools: vec![],
system: None,
think: false,
max_tokens: MAX_OUTPUT_TOKENS,
server_tools: vec![],
enable_caching: false, };
let response = provider.chat(request).await
.with_context(|| "calling AI for overview generation")?;
let content = extract_response_content(&response);
let path = overview_path(project_root);
fs::write(&path, &content)
.with_context(|| format!("writing overview file {}", path.display()))?;
Ok(Self { content, path })
}
pub fn clear(project_root: &Path) -> Result<()> {
let path = overview_path(project_root);
if path.exists() {
fs::remove_file(&path)
.with_context(|| format!("removing overview file {}", path.display()))?;
}
Ok(())
}
pub fn exists(project_root: &Path) -> bool {
overview_path(project_root).exists()
}
pub fn path(project_root: &Path) -> PathBuf {
overview_path(project_root)
}
}
fn overview_path(project_root: &Path) -> PathBuf {
project_root.join(OVERVIEW_FILENAME)
}
const IGNORE_PATTERNS: &[&str] = &[
".git",
".svn",
".hg",
"node_modules",
"vendor",
"target",
"target-test",
"build",
"dist",
"out",
"bin",
"obj",
".cargo",
".idea",
".vscode",
".vs",
".claude",
".matrix",
".cache",
"__pycache__",
"*.pyc",
".DS_Store",
"Thumbs.db",
"Cargo.lock",
"package-lock.json",
"yarn.lock",
"pnpm-lock.yaml",
"*.generated.*",
"swagger.json",
"swagger.yaml",
];
pub fn should_ignore(name: &str) -> bool {
if IGNORE_PATTERNS.contains(&name) {
return true;
}
for pattern in IGNORE_PATTERNS {
if pattern.starts_with("*.") {
let suffix = &pattern[1..];
if name.ends_with(suffix) {
return true;
}
}
}
false
}
struct ProjectContext {
config_files: Vec<(String, String)>,
readme: Option<String>,
directory_structure: String,
source_files: Vec<(String, String)>,
project_type: &'static str,
}
fn collect_project_context(project_root: &Path) -> Result<ProjectContext> {
let project_type = detect_project_type(project_root);
let config_files = collect_config_files(project_root)?;
let readme = read_readme(project_root)?;
let directory_structure = build_directory_structure(project_root)?;
let source_files = collect_key_source_files(project_root, &project_type)?;
Ok(ProjectContext {
config_files,
readme,
directory_structure,
source_files,
project_type,
})
}
pub fn detect_project_type(project_root: &Path) -> &'static str {
for config in PROJECT_TYPE_CONFIGS {
for detect_file in config.detect_files {
if project_root.join(detect_file).exists() {
return config.type_name;
}
}
}
PROJECT_TYPE_UNKNOWN
}
fn collect_config_files(project_root: &Path) -> Result<Vec<(String, String)>> {
let mut files = Vec::new();
for filename in CONFIG_FILENAMES {
let path = project_root.join(filename);
if path.exists() {
let content = fs::read_to_string(&path)
.with_context(|| format!("reading {}", filename))?;
let truncated = truncate_content(&content, CONFIG_FILE_MAX_CHARS);
files.push((filename.to_string(), truncated));
}
}
Ok(files)
}
fn read_readme(project_root: &Path) -> Result<Option<String>> {
let readme_path = project_root.join(README_FILENAME);
if !readme_path.exists() {
return Ok(None);
}
let content = fs::read_to_string(&readme_path)
.with_context(|| format!("reading {}", README_FILENAME))?;
Ok(Some(truncate_content(&content, README_MAX_CHARS)))
}
fn build_directory_structure(project_root: &Path) -> Result<String> {
let mut result = String::new();
result.push_str(&format!("{}/\n", project_root.file_name().and_then(|n| n.to_str()).unwrap_or(DEFAULT_PROJECT_NAME)));
build_tree_recursive(project_root, 0, DIRECTORY_MAX_DEPTH, &mut result)?;
Ok(result)
}
fn build_tree_recursive(dir: &Path, depth: usize, max_depth: usize, result: &mut String) -> Result<()> {
if depth > max_depth {
result.push_str(&format!("{} ...\n", " ".repeat(depth)));
return Ok(());
}
let entries = fs::read_dir(dir).ok();
if entries.is_none() {
return Ok(());
}
let mut dirs: Vec<String> = Vec::new();
let mut files: Vec<String> = Vec::new();
for entry in entries.unwrap().flatten() {
let name = entry.file_name().to_string_lossy().into_owned();
if should_ignore(&name) {
continue;
}
if entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
dirs.push(name);
} else {
files.push(name);
}
}
dirs.sort();
files.sort();
let indent = " ".repeat(depth);
let max_items = if depth == 0 { DIRECTORY_ROOT_MAX_ITEMS } else { DIRECTORY_OTHER_MAX_ITEMS };
let mut count = 0;
for d in &dirs {
if count >= max_items {
result.push_str(&format!("{} ... ({} more dirs)\n", indent, dirs.len() - count));
break;
}
result.push_str(&format!("{} {}/\n", indent, d));
build_tree_recursive(&dir.join(d), depth + 1, max_depth, result)?;
count += 1;
}
for f in files.iter().take(max_items - count) {
result.push_str(&format!("{} {}\n", indent, f));
}
if files.len() > max_items - count {
result.push_str(&format!("{} ... ({} more files)\n", indent, files.len() - (max_items - count)));
}
Ok(())
}
fn collect_key_source_files(project_root: &Path, project_type: &str) -> Result<Vec<(String, String)>> {
let mut files = Vec::new();
let config = PROJECT_TYPE_CONFIGS.iter()
.find(|c| c.type_name == project_type);
if let Some(config) = config {
for path_str in config.key_source_files {
let path = project_root.join(path_str);
if path.exists() {
let content = fs::read_to_string(&path).ok();
if let Some(content) = content {
files.push((path_str.to_string(), truncate_content(&content, SOURCE_FILE_MAX_CHARS)));
}
}
}
}
if project_type == "Rust" {
let lib_path = project_root.join(SRC_DIR).join(RUST_LIB_FILE);
if lib_path.exists() {
let lib_relative = format!("{}/{}", SRC_DIR, RUST_LIB_FILE);
let content = fs::read_to_string(&lib_path).ok();
if let Some(content) = content {
files.push((lib_relative, truncate_content(&content, SOURCE_FILE_MAX_CHARS)));
}
let src_path = project_root.join(SRC_DIR);
if src_path.exists() {
for entry in fs::read_dir(&src_path)?.flatten() {
let name = entry.file_name().to_string_lossy().into_owned();
if entry.file_type().map(|t| t.is_dir()).unwrap_or(false) && !should_ignore(&name) {
let mod_path = src_path.join(&name).join(RUST_MOD_FILE);
if mod_path.exists() {
let content = fs::read_to_string(&mod_path).ok();
if let Some(content) = content {
let mod_relative = format!("{}/{}/{}", SRC_DIR, name, RUST_MOD_FILE);
files.push((mod_relative, truncate_content(&content, MODULE_FILE_MAX_CHARS)));
}
}
}
}
}
}
}
Ok(files)
}
pub fn truncate_content(content: &str, max_len: usize) -> String {
if content.len() <= max_len {
content.to_string()
} else {
let mut end = max_len;
while end > 0 && !content.is_char_boundary(end) {
end -= 1;
}
let mut truncated = content[..end].to_string();
truncated.push_str("\n... (truncated)");
truncated
}
}
fn extract_response_content(response: &crate::providers::ChatResponse) -> String {
let mut content = String::new();
for block in &response.content {
if let crate::providers::ContentBlock::Text { text } = block {
content.push_str(text);
}
}
content
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn truncate_content_respects_char_boundary() {
let text = "这是一个包含中文字符的测试文本,用于验证截断功能是否正确处理字符边界问题。";
let truncated = truncate_content(text, 50);
assert!(truncated.contains("... (truncated)"));
}
#[test]
fn truncate_content_preserves_short_text() {
let short = "hello world";
let result = truncate_content(short, 100);
assert_eq!(result, short);
}
#[test]
fn truncate_content_exact_boundary() {
let text = "abcdefghijklmnopqrstuvwxyz";
let truncated = truncate_content(text, 10);
assert_eq!(truncated, "abcdefghij\n... (truncated)");
}
#[test]
fn truncate_content_multibyte_edge() {
let text = "你好世界hello";
let truncated = truncate_content(text, 12); assert!(truncated.starts_with("你好世界"));
}
}