Skip to main content

hematite/agent/
workspace_profile.rs

1use serde::{Deserialize, Serialize};
2use std::collections::BTreeSet;
3use std::path::{Path, PathBuf};
4
5#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
6pub struct WorkspaceProfile {
7    pub workspace_mode: String,
8    pub primary_stack: Option<String>,
9    #[serde(default)]
10    pub stack_signals: Vec<String>,
11    #[serde(default)]
12    pub package_managers: Vec<String>,
13    #[serde(default)]
14    pub important_paths: Vec<String>,
15    #[serde(default)]
16    pub ignored_paths: Vec<String>,
17    pub verify_profile: Option<String>,
18    pub build_hint: Option<String>,
19    pub test_hint: Option<String>,
20    pub summary: String,
21}
22
23pub fn workspace_profile_path(root: &Path) -> PathBuf {
24    root.join(".hematite").join("workspace_profile.json")
25}
26
27pub fn ensure_workspace_profile(root: &Path) -> Result<WorkspaceProfile, String> {
28    let profile = detect_workspace_profile(root);
29    let path = workspace_profile_path(root);
30    if let Some(parent) = path.parent() {
31        std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
32    }
33
34    let json = serde_json::to_string_pretty(&profile).map_err(|e| e.to_string())?;
35    let existing = std::fs::read_to_string(&path).ok();
36    if existing.as_deref() != Some(json.as_str()) {
37        std::fs::write(&path, json).map_err(|e| e.to_string())?;
38    }
39
40    Ok(profile)
41}
42
43pub fn load_workspace_profile(root: &Path) -> Option<WorkspaceProfile> {
44    let path = workspace_profile_path(root);
45    std::fs::read_to_string(path)
46        .ok()
47        .and_then(|raw| serde_json::from_str(&raw).ok())
48}
49
50pub fn profile_prompt_block(root: &Path) -> Option<String> {
51    let profile = load_workspace_profile(root).unwrap_or_else(|| detect_workspace_profile(root));
52    if profile.summary.trim().is_empty() {
53        return None;
54    }
55
56    let mut lines = vec![format!("Summary: {}", profile.summary)];
57    if let Some(stack) = &profile.primary_stack {
58        lines.push(format!("Primary stack: {}", stack));
59    }
60    if !profile.package_managers.is_empty() {
61        lines.push(format!(
62            "Package managers: {}",
63            profile.package_managers.join(", ")
64        ));
65    }
66    if let Some(profile_name) = &profile.verify_profile {
67        lines.push(format!("Verify profile: {}", profile_name));
68    }
69    if let Some(build_hint) = &profile.build_hint {
70        lines.push(format!("Build hint: {}", build_hint));
71    }
72    if let Some(test_hint) = &profile.test_hint {
73        lines.push(format!("Test hint: {}", test_hint));
74    }
75    if !profile.important_paths.is_empty() {
76        lines.push(format!(
77            "Important paths: {}",
78            profile.important_paths.join(", ")
79        ));
80    }
81    if !profile.ignored_paths.is_empty() {
82        lines.push(format!(
83            "Ignore noise from: {}",
84            profile.ignored_paths.join(", ")
85        ));
86    }
87
88    Some(format!(
89        "# Workspace Profile (auto-generated)\n{}",
90        lines.join("\n")
91    ))
92}
93
94pub fn profile_report(root: &Path) -> String {
95    let profile = load_workspace_profile(root).unwrap_or_else(|| detect_workspace_profile(root));
96    let path = workspace_profile_path(root);
97
98    let mut out = String::new();
99    out.push_str("Workspace Profile\n");
100    out.push_str(&format!("Path: {}\n", path.display()));
101    out.push_str(&format!("Mode: {}\n", profile.workspace_mode));
102    out.push_str(&format!(
103        "Primary stack: {}\n",
104        profile.primary_stack.as_deref().unwrap_or("unknown")
105    ));
106    if !profile.stack_signals.is_empty() {
107        out.push_str(&format!(
108            "Stack signals: {}\n",
109            profile.stack_signals.join(", ")
110        ));
111    }
112    if !profile.package_managers.is_empty() {
113        out.push_str(&format!(
114            "Package managers: {}\n",
115            profile.package_managers.join(", ")
116        ));
117    }
118    if let Some(profile_name) = &profile.verify_profile {
119        out.push_str(&format!("Verify profile: {}\n", profile_name));
120    }
121    if let Some(build_hint) = &profile.build_hint {
122        out.push_str(&format!("Build hint: {}\n", build_hint));
123    }
124    if let Some(test_hint) = &profile.test_hint {
125        out.push_str(&format!("Test hint: {}\n", test_hint));
126    }
127    if !profile.important_paths.is_empty() {
128        out.push_str(&format!(
129            "Important paths: {}\n",
130            profile.important_paths.join(", ")
131        ));
132    }
133    if !profile.ignored_paths.is_empty() {
134        out.push_str(&format!(
135            "Ignored noise: {}\n",
136            profile.ignored_paths.join(", ")
137        ));
138    }
139    out.push_str(&format!("Summary: {}", profile.summary));
140    out
141}
142
143pub fn detect_workspace_profile(root: &Path) -> WorkspaceProfile {
144    let is_project = looks_like_project_root(root);
145    let workspace_mode = if is_project {
146        "project"
147    } else if root.join(".hematite").join("docs").exists()
148        || root.join(".hematite").join("imports").exists()
149    {
150        "docs_only"
151    } else {
152        "general"
153    }
154    .to_string();
155
156    let mut stack_signals = BTreeSet::new();
157    let mut package_managers = BTreeSet::new();
158
159    if root.join("Cargo.toml").exists() {
160        stack_signals.insert("rust".to_string());
161        package_managers.insert("cargo".to_string());
162    }
163    if root.join("package.json").exists() {
164        stack_signals.insert("node".to_string());
165        package_managers.insert(detect_node_package_manager(root));
166    }
167    if root.join("pyproject.toml").exists() || root.join("setup.py").exists() {
168        stack_signals.insert("python".to_string());
169        package_managers.insert(detect_python_package_manager(root));
170    }
171    if root.join("go.mod").exists() {
172        stack_signals.insert("go".to_string());
173        package_managers.insert("go".to_string());
174    }
175    if root.join("pom.xml").exists() {
176        stack_signals.insert("java".to_string());
177        package_managers.insert("maven".to_string());
178    }
179    if root.join("build.gradle").exists() || root.join("build.gradle.kts").exists() {
180        stack_signals.insert("java".to_string());
181        package_managers.insert("gradle".to_string());
182    }
183    if root.join("CMakeLists.txt").exists() {
184        stack_signals.insert("cpp".to_string());
185        package_managers.insert("cmake".to_string());
186    }
187    if has_extension_in_dir(root, "sln") || has_extension_in_dir(root, "csproj") {
188        stack_signals.insert("dotnet".to_string());
189        package_managers.insert("dotnet".to_string());
190    }
191    if root.join(".git").exists() && stack_signals.is_empty() {
192        stack_signals.insert("git".to_string());
193    }
194
195    let primary_stack = stack_signals
196        .iter()
197        .find(|stack| stack.as_str() != "git")
198        .cloned()
199        .or_else(|| stack_signals.iter().next().cloned());
200
201    let important_paths = collect_existing_paths(
202        root,
203        &[
204            "src",
205            "tests",
206            "docs",
207            "installer",
208            "scripts",
209            ".github/workflows",
210            ".hematite/docs",
211            ".hematite/imports",
212        ],
213    );
214    let ignored_paths = collect_existing_paths(
215        root,
216        &[
217            "target",
218            "node_modules",
219            ".git",
220            ".hematite/reports",
221            ".hematite/scratch",
222        ],
223    );
224
225    let verify = load_workspace_verify_config(root);
226    let verify_profile = verify.default_profile.clone();
227    let (build_hint, test_hint) = if let Some(profile_name) = verify_profile.as_deref() {
228        if let Some(profile) = verify.profiles.get(profile_name) {
229            (profile.build.clone(), profile.test.clone())
230        } else {
231            (
232                default_build_hint(root, primary_stack.as_deref()),
233                default_test_hint(root, primary_stack.as_deref()),
234            )
235        }
236    } else {
237        (
238            default_build_hint(root, primary_stack.as_deref()),
239            default_test_hint(root, primary_stack.as_deref()),
240        )
241    };
242
243    let summary = build_summary(
244        &workspace_mode,
245        primary_stack.as_deref(),
246        &important_paths,
247        verify_profile.as_deref(),
248        build_hint.as_deref(),
249        test_hint.as_deref(),
250    );
251
252    WorkspaceProfile {
253        workspace_mode,
254        primary_stack,
255        stack_signals: stack_signals.into_iter().collect(),
256        package_managers: package_managers
257            .into_iter()
258            .filter(|entry| !entry.is_empty())
259            .collect(),
260        important_paths,
261        ignored_paths,
262        verify_profile,
263        build_hint,
264        test_hint,
265        summary,
266    }
267}
268
269fn looks_like_project_root(root: &Path) -> bool {
270    root.join("Cargo.toml").exists()
271        || root.join("package.json").exists()
272        || root.join("pyproject.toml").exists()
273        || root.join("go.mod").exists()
274        || root.join("setup.py").exists()
275        || root.join("pom.xml").exists()
276        || root.join("build.gradle").exists()
277        || root.join("build.gradle.kts").exists()
278        || root.join("CMakeLists.txt").exists()
279        || (root.join(".git").exists() && root.join("src").exists())
280}
281
282fn has_extension_in_dir(root: &Path, ext: &str) -> bool {
283    std::fs::read_dir(root)
284        .ok()
285        .into_iter()
286        .flat_map(|entries| entries.filter_map(|entry| entry.ok()))
287        .any(|entry| {
288            entry
289                .path()
290                .extension()
291                .and_then(|value| value.to_str())
292                .map(|value| value.eq_ignore_ascii_case(ext))
293                .unwrap_or(false)
294        })
295}
296
297fn detect_node_package_manager(root: &Path) -> String {
298    if root.join("pnpm-lock.yaml").exists() {
299        "pnpm".to_string()
300    } else if root.join("yarn.lock").exists() {
301        "yarn".to_string()
302    } else if root.join("bun.lockb").exists() || root.join("bun.lock").exists() {
303        "bun".to_string()
304    } else {
305        "npm".to_string()
306    }
307}
308
309fn detect_python_package_manager(root: &Path) -> String {
310    let pyproject = root.join("pyproject.toml");
311    if let Ok(content) = std::fs::read_to_string(pyproject) {
312        let lower = content.to_ascii_lowercase();
313        if lower.contains("[tool.uv") {
314            return "uv".to_string();
315        }
316        if lower.contains("[tool.poetry") {
317            return "poetry".to_string();
318        }
319        if lower.contains("[project]") {
320            return "pip/pyproject".to_string();
321        }
322    }
323    "pip".to_string()
324}
325
326fn collect_existing_paths(root: &Path, candidates: &[&str]) -> Vec<String> {
327    candidates
328        .iter()
329        .filter(|candidate| root.join(candidate).exists())
330        .map(|candidate| candidate.replace('\\', "/"))
331        .collect()
332}
333
334fn default_build_hint(root: &Path, primary_stack: Option<&str>) -> Option<String> {
335    match primary_stack {
336        Some("rust") => Some("cargo build".to_string()),
337        Some("node") => {
338            if root.join("package.json").exists() {
339                Some(format!("{} run build", detect_node_package_manager(root)))
340            } else {
341                None
342            }
343        }
344        Some("python") => None,
345        Some("go") => Some("go build ./...".to_string()),
346        Some("java") => {
347            if root.join("pom.xml").exists() {
348                Some("mvn -q -DskipTests package".to_string())
349            } else if root.join("build.gradle").exists() || root.join("build.gradle.kts").exists() {
350                Some("./gradlew build".to_string())
351            } else {
352                None
353            }
354        }
355        Some("cpp") => Some("cmake --build build".to_string()),
356        _ => None,
357    }
358}
359
360fn default_test_hint(root: &Path, primary_stack: Option<&str>) -> Option<String> {
361    match primary_stack {
362        Some("rust") => Some("cargo test".to_string()),
363        Some("node") => Some(format!("{} test", detect_node_package_manager(root))),
364        Some("python") => {
365            if root.join("tests").exists() || root.join("test").exists() {
366                Some("pytest".to_string())
367            } else {
368                None
369            }
370        }
371        Some("go") => Some("go test ./...".to_string()),
372        Some("java") => {
373            if root.join("pom.xml").exists() {
374                Some("mvn test".to_string())
375            } else if root.join("build.gradle").exists() || root.join("build.gradle.kts").exists() {
376                Some("./gradlew test".to_string())
377            } else {
378                None
379            }
380        }
381        _ => None,
382    }
383}
384
385fn build_summary(
386    workspace_mode: &str,
387    primary_stack: Option<&str>,
388    important_paths: &[String],
389    verify_profile: Option<&str>,
390    build_hint: Option<&str>,
391    test_hint: Option<&str>,
392) -> String {
393    let mut parts = Vec::new();
394    match workspace_mode {
395        "project" => {
396            if let Some(stack) = primary_stack {
397                parts.push(format!("{stack} project workspace"));
398            } else {
399                parts.push("project workspace".to_string());
400            }
401        }
402        "docs_only" => parts.push("docs-only workspace".to_string()),
403        _ => parts.push("general local workspace".to_string()),
404    }
405
406    if !important_paths.is_empty() {
407        parts.push(format!("key paths: {}", important_paths.join(", ")));
408    }
409    if let Some(profile) = verify_profile {
410        parts.push(format!("verify profile: {}", profile));
411    } else if let Some(build) = build_hint {
412        parts.push(format!("suggested build: {}", build));
413    }
414    if let Some(test) = test_hint {
415        parts.push(format!("suggested test: {}", test));
416    }
417
418    parts.join(" | ")
419}
420
421fn load_workspace_verify_config(root: &Path) -> crate::agent::config::VerifyProfilesConfig {
422    let path = root.join(".hematite").join("settings.json");
423    std::fs::read_to_string(path)
424        .ok()
425        .and_then(|raw| serde_json::from_str::<crate::agent::config::HematiteConfig>(&raw).ok())
426        .map(|config| config.verify)
427        .unwrap_or_default()
428}