Skip to main content

st/
context.rs

1//! Project context detection module
2
3use serde_json::Value;
4use std::fs;
5use std::path::Path;
6
7/// Attempts to detect the project type and description
8pub fn detect_project_context(root_path: &Path) -> Option<String> {
9    // Try various project files in order of preference
10
11    // Rust projects
12    if let Some(context) = read_cargo_toml(root_path) {
13        return Some(context);
14    }
15
16    // Node.js projects
17    if let Some(context) = read_package_json(root_path) {
18        return Some(context);
19    }
20
21    // Python projects
22    if let Some(context) = read_pyproject_toml(root_path) {
23        return Some(context);
24    }
25
26    // Go projects
27    if let Some(context) = read_go_mod(root_path) {
28        return Some(context);
29    }
30
31    // Git repositories
32    if let Some(context) = read_git_description(root_path) {
33        return Some(context);
34    }
35
36    // README files
37    if let Some(context) = read_readme(root_path) {
38        return Some(context);
39    }
40
41    None
42}
43
44fn read_cargo_toml(root_path: &Path) -> Option<String> {
45    let cargo_path = root_path.join("Cargo.toml");
46    if !cargo_path.exists() {
47        return None;
48    }
49
50    let content = fs::read_to_string(&cargo_path).ok()?;
51    let toml: toml::Value = toml::from_str(&content).ok()?;
52
53    let package = toml.get("package")?;
54    let name = package.get("name")?.as_str()?;
55    let desc = package.get("description")?.as_str()?;
56
57    Some(format!("Rust: {} - {}", name, truncate_string(desc, 80)))
58}
59
60fn read_package_json(root_path: &Path) -> Option<String> {
61    let package_path = root_path.join("package.json");
62    if !package_path.exists() {
63        return None;
64    }
65
66    let content = fs::read_to_string(&package_path).ok()?;
67    let json: Value = serde_json::from_str(&content).ok()?;
68
69    let name = json.get("name")?.as_str()?;
70    let desc = json
71        .get("description")?
72        .as_str()
73        .unwrap_or("No description");
74
75    Some(format!("Node: {} - {}", name, truncate_string(desc, 80)))
76}
77
78fn read_pyproject_toml(root_path: &Path) -> Option<String> {
79    let pyproject_path = root_path.join("pyproject.toml");
80    if !pyproject_path.exists() {
81        return None;
82    }
83
84    let content = fs::read_to_string(&pyproject_path).ok()?;
85    let toml: toml::Value = toml::from_str(&content).ok()?;
86
87    // Try both [project] and [tool.poetry] sections
88    if let Some(project) = toml.get("project") {
89        let name = project.get("name")?.as_str()?;
90        let desc = project
91            .get("description")?
92            .as_str()
93            .unwrap_or("No description");
94        return Some(format!("Python: {} - {}", name, truncate_string(desc, 80)));
95    } else if let Some(tool) = toml.get("tool") {
96        if let Some(poetry) = tool.get("poetry") {
97            let name = poetry.get("name")?.as_str()?;
98            let desc = poetry
99                .get("description")?
100                .as_str()
101                .unwrap_or("No description");
102            return Some(format!("Python: {} - {}", name, truncate_string(desc, 80)));
103        }
104    }
105
106    None
107}
108
109fn read_go_mod(root_path: &Path) -> Option<String> {
110    let go_mod_path = root_path.join("go.mod");
111    if !go_mod_path.exists() {
112        return None;
113    }
114
115    let content = fs::read_to_string(&go_mod_path).ok()?;
116    let first_line = content.lines().next()?;
117
118    if first_line.starts_with("module ") {
119        let module_name = first_line.strip_prefix("module ")?.trim();
120        return Some(format!("Go: {}", module_name));
121    }
122
123    None
124}
125
126fn read_git_description(root_path: &Path) -> Option<String> {
127    let git_desc_path = root_path.join(".git/description");
128    if !git_desc_path.exists() {
129        return None;
130    }
131
132    let content = fs::read_to_string(&git_desc_path).ok()?;
133    let desc = content.trim();
134
135    // Skip the default git description
136    if desc.contains("Unnamed repository") {
137        return None;
138    }
139
140    Some(format!("Git: {}", truncate_string(desc, 80)))
141}
142
143fn read_readme(root_path: &Path) -> Option<String> {
144    // Try various README filenames
145    let readme_names = [
146        "README.md",
147        "README.MD",
148        "readme.md",
149        "README",
150        "README.txt",
151    ];
152
153    for name in &readme_names {
154        let readme_path = root_path.join(name);
155        if readme_path.exists() {
156            let content = fs::read_to_string(&readme_path).ok()?;
157
158            // Extract first non-empty line after any headers
159            for line in content.lines() {
160                let trimmed = line.trim();
161                // Skip empty lines and markdown headers
162                if !trimmed.is_empty() && !trimmed.starts_with('#') {
163                    return Some(truncate_string(trimmed, 100));
164                }
165            }
166        }
167    }
168
169    None
170}
171
172fn truncate_string(s: &str, max_len: usize) -> String {
173    if s.chars().count() <= max_len {
174        s.to_string()
175    } else {
176        let truncated: String = s.chars().take(max_len.saturating_sub(3)).collect();
177        format!("{}...", truncated)
178    }
179}