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