Skip to main content

winx_code_agent/utils/
repo.rs

1use crate::errors::Result;
2use std::fmt::Write as FmtWrite;
3use std::fs;
4use std::path::{Path, PathBuf};
5use std::process::Command;
6
7const MAX_DEPTH: usize = 10;
8const MAX_CONTEXT_FILES: usize = 160;
9const MAX_RECENT_FILES: usize = 30;
10
11const IMPORTANT_NAMES: &[&str] = &[
12    "Cargo.toml",
13    "README.md",
14    "AGENTS.md",
15    "package.json",
16    "pnpm-workspace.yaml",
17    "pyproject.toml",
18    "go.mod",
19    "Dockerfile",
20    "docker-compose.yml",
21    ".github/workflows/ci.yml",
22];
23
24const SKIP_DIRS: &[&str] =
25    &[".git", ".winx", "target", "node_modules", ".next", "dist", "build", ".venv", "__pycache__"];
26
27#[derive(Debug, Clone)]
28pub struct RepoContext {
29    pub root: PathBuf,
30    pub is_git_repo: bool,
31    pub project_summary: String,
32    pub recent_files: Vec<String>,
33    pub active_files: Vec<String>,
34    pub important_files: Vec<String>,
35    pub project_files: Vec<String>,
36}
37
38pub struct RepoContextAnalyzer;
39
40impl RepoContextAnalyzer {
41    pub fn analyze(path: &Path) -> Result<RepoContext> {
42        let root = workspace_root(path);
43        let is_git_repo = root.join(".git").exists();
44        let mut project_files = collect_project_files(&root)?;
45        let active_files = crate::utils::workspace_stats::active_files(&root);
46        project_files.sort_by_key(|path| (path_score(path, &active_files), path.clone()));
47        project_files.truncate(MAX_CONTEXT_FILES);
48
49        let recent_files = if is_git_repo { recent_git_files(&root) } else { Vec::new() };
50        let important_files = important_files(&project_files);
51        let project_summary = project_summary(&root, is_git_repo, project_files.len());
52
53        Ok(RepoContext {
54            root,
55            is_git_repo,
56            project_summary,
57            recent_files,
58            active_files,
59            important_files,
60            project_files,
61        })
62    }
63}
64
65pub fn get_repo_context(path: &Path) -> Result<(String, Vec<String>)> {
66    let context = RepoContextAnalyzer::analyze(path)?;
67    let mut output = String::new();
68
69    let _ = writeln!(output, "Project root: {}", context.root.display());
70    let _ = writeln!(output, "Git repository: {}", if context.is_git_repo { "yes" } else { "no" });
71    let _ = writeln!(output, "{}", context.project_summary);
72
73    if !context.important_files.is_empty() {
74        output.push_str("\nImportant files:\n");
75        for file in &context.important_files {
76            let _ = writeln!(output, "- {file}");
77        }
78    }
79
80    if !context.recent_files.is_empty() {
81        output.push_str("\nRecent git files:\n");
82        for file in &context.recent_files {
83            let _ = writeln!(output, "- {file}");
84        }
85    }
86
87    if !context.active_files.is_empty() {
88        output.push_str("\nActive winx files:\n");
89        for file in &context.active_files {
90            let _ = writeln!(output, "- {file}");
91        }
92    }
93
94    output.push_str("\nWorkspace files:\n");
95    for file in &context.project_files {
96        let _ = writeln!(output, "- {file}");
97    }
98
99    Ok((output, context.project_files))
100}
101
102fn workspace_root(path: &Path) -> PathBuf {
103    if path.is_file() {
104        path.parent().unwrap_or(path).to_path_buf()
105    } else {
106        path.to_path_buf()
107    }
108}
109
110fn collect_project_files(root: &Path) -> Result<Vec<String>> {
111    let mut files = Vec::new();
112    collect_files(root, root, 0, &mut files)?;
113    Ok(files)
114}
115
116fn collect_files(root: &Path, current: &Path, depth: usize, files: &mut Vec<String>) -> Result<()> {
117    if depth > MAX_DEPTH {
118        return Ok(());
119    }
120
121    let mut entries = fs::read_dir(current)?.collect::<std::result::Result<Vec<_>, _>>()?;
122    entries.sort_by_key(std::fs::DirEntry::path);
123
124    for entry in entries {
125        let path = entry.path();
126        let name = entry.file_name();
127        let name = name.to_string_lossy();
128        if path.is_dir() {
129            if !SKIP_DIRS.contains(&name.as_ref()) {
130                collect_files(root, &path, depth + 1, files)?;
131            }
132        } else if path.is_file() {
133            if let Ok(relative) = path.strip_prefix(root) {
134                files.push(relative.to_string_lossy().to_string());
135            }
136        }
137    }
138
139    Ok(())
140}
141
142fn important_files(files: &[String]) -> Vec<String> {
143    files.iter().filter(|file| IMPORTANT_NAMES.contains(&file.as_str())).cloned().collect()
144}
145
146fn recent_git_files(root: &Path) -> Vec<String> {
147    let output = Command::new("git")
148        .args(["-C"])
149        .arg(root)
150        .args(["log", "--name-only", "--pretty=format:", "-n", "50"])
151        .output();
152
153    let Ok(output) = output else {
154        return Vec::new();
155    };
156    if !output.status.success() {
157        return Vec::new();
158    }
159
160    let mut recent = Vec::new();
161    for line in String::from_utf8_lossy(&output.stdout).lines().map(str::trim) {
162        if !line.is_empty() && !recent.iter().any(|existing| existing == line) {
163            recent.push(line.to_string());
164        }
165        if recent.len() >= MAX_RECENT_FILES {
166            break;
167        }
168    }
169    recent
170}
171
172fn project_summary(root: &Path, is_git_repo: bool, file_count: usize) -> String {
173    let manifest = if root.join("Cargo.toml").exists() {
174        "Rust/Cargo"
175    } else if root.join("package.json").exists() {
176        "Node.js"
177    } else if root.join("pyproject.toml").exists() {
178        "Python"
179    } else {
180        "generic"
181    };
182    format!("Detected {manifest} workspace with {file_count} indexed files; git={is_git_repo}.")
183}
184
185fn path_score(path: &str, active_files: &[String]) -> usize {
186    let important = usize::from(!IMPORTANT_NAMES.contains(&path));
187    let active_bonus = if active_files.iter().any(|active| active == path) { 0 } else { 8 };
188    let depth = path.matches('/').count();
189    let test_penalty = usize::from(path.contains("test") || path.contains("spec"));
190    active_bonus + important * 10 + depth + test_penalty
191}
192
193#[cfg(test)]
194mod tests {
195    use super::get_repo_context;
196    use crate::errors::Result;
197    use tempfile::TempDir;
198
199    #[test]
200    fn builds_repo_context_from_files() -> Result<()> {
201        let temp_dir = TempDir::new()?;
202        std::fs::write(temp_dir.path().join("Cargo.toml"), "[package]\nname='x'\n")?;
203        std::fs::create_dir(temp_dir.path().join("src"))?;
204        std::fs::write(temp_dir.path().join("src/lib.rs"), "pub fn x() {}\n")?;
205
206        let (context, files) = get_repo_context(temp_dir.path())?;
207        assert!(context.contains("Cargo.toml"));
208        assert!(files.iter().any(|file| file == "src/lib.rs"));
209        Ok(())
210    }
211}