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 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}