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}