1use crate::{IndexedNode, NodeKind, PurposeStatus, is_high_impact_file_path, normalized_parent};
4use serde::{Deserialize, Serialize};
5use std::{collections::HashMap, fmt, str::FromStr};
6
7pub const CATEGORY_MISSING_PURPOSE: &str = "missing-purpose";
9pub const CATEGORY_SUGGESTED_PURPOSE_REVIEW: &str = "suggested-purpose-review";
11pub const CATEGORY_STALE_PURPOSE: &str = "stale-purpose";
13pub const CATEGORY_PURPOSE_AGENT_REVIEW_REQUIRED: &str = "purpose-agent-review-required";
15pub const CATEGORY_DUPLICATE_PURPOSE: &str = "duplicate-purpose";
17pub const CATEGORY_REPEATED_TEMPORARY_FOLDER: &str = "repeated-temporary-folder";
19pub const STRUCTURAL_HEALTH_CATEGORIES: [&str; 3] = [
21 CATEGORY_PURPOSE_AGENT_REVIEW_REQUIRED,
22 CATEGORY_DUPLICATE_PURPOSE,
23 CATEGORY_REPEATED_TEMPORARY_FOLDER,
24];
25pub const TEMP_FOLDER_BUCKETS: [&str; 6] = ["tmp", "temp", "cache", "generated", "out", "output"];
27
28pub const MESSAGE_MISSING_PURPOSE: &str = "Path is indexed but has no approved purpose.";
30pub const RECOMMENDATION_MISSING_PURPOSE: &str =
32 "Set or approve a one-line purpose in the ProjectAtlas index.";
33pub const RECOMMENDATION_MISSING_PURPOSE_QUEUE: &str =
35 "Set an agent-reviewed one-line purpose in the ProjectAtlas index.";
36pub const MESSAGE_SUGGESTED_PURPOSE_REVIEW: &str =
38 "Path has a generated purpose suggestion but no agent-approved purpose.";
39pub const RECOMMENDATION_SUGGESTED_PURPOSE_REVIEW: &str =
41 "Inspect the folder/file summary and approve or correct the purpose in SQLite.";
42pub const RECOMMENDATION_SUGGESTED_PURPOSE_REVIEW_QUEUE: &str =
44 "Inspect enough context and approve or correct the purpose in SQLite.";
45pub const MESSAGE_STALE_PURPOSE: &str =
47 "Accepted purpose is in a legacy or explicitly flagged review state.";
48pub const RECOMMENDATION_STALE_PURPOSE: &str =
50 "Explicitly approve the existing responsibility or correct it if inconsistent.";
51pub const MESSAGE_PURPOSE_AGENT_REVIEW_REQUIRED: &str =
53 "Purpose is approved but has not been reviewed by an agent.";
54pub const RECOMMENDATION_PURPOSE_AGENT_REVIEW_REQUIRED: &str =
56 "Inspect current context and approve or correct the purpose with purpose set.";
57pub const RECOMMENDATION_DUPLICATE_PURPOSE: &str =
59 "Review whether these paths duplicate responsibility or need clearer purposes.";
60pub const RECOMMENDATION_REPEATED_TEMPORARY_FOLDER: &str =
62 "Consolidate temporary/generated output roots or add an allowlist rationale.";
63
64#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
66#[serde(rename_all = "lowercase")]
67pub enum Severity {
68 Info,
70 Warning,
72 Error,
74}
75
76impl Severity {
77 #[must_use]
79 pub const fn as_str(self) -> &'static str {
80 match self {
81 Self::Info => "info",
82 Self::Warning => "warning",
83 Self::Error => "error",
84 }
85 }
86}
87
88impl fmt::Display for Severity {
89 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
90 formatter.write_str(self.as_str())
91 }
92}
93
94#[derive(Clone, Debug, Eq, PartialEq)]
96pub struct ParseSeverityError;
97
98impl fmt::Display for ParseSeverityError {
99 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
100 formatter.write_str("invalid health severity")
101 }
102}
103
104impl std::error::Error for ParseSeverityError {}
105
106impl FromStr for Severity {
107 type Err = ParseSeverityError;
108
109 fn from_str(value: &str) -> Result<Self, Self::Err> {
110 match value.trim().to_ascii_lowercase().as_str() {
111 value if value == Self::Info.as_str() => Ok(Self::Info),
112 value if value == Self::Warning.as_str() => Ok(Self::Warning),
113 value if value == Self::Error.as_str() => Ok(Self::Error),
114 _ => Err(ParseSeverityError),
115 }
116 }
117}
118
119#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
121pub struct HealthFinding {
122 pub id: String,
124 pub severity: Severity,
126 pub category: String,
128 pub path: String,
130 pub related_path: Option<String>,
132 pub message: String,
134 pub recommendation: String,
136}
137
138#[must_use]
140pub fn health_check(nodes: &[IndexedNode]) -> Vec<HealthFinding> {
141 let mut findings = Vec::new();
142 findings.extend(missing_purpose_findings(nodes));
143 findings.extend(suggested_purpose_findings(nodes));
144 findings.extend(stale_purpose_findings(nodes));
145 findings.extend(agent_review_required_findings(nodes));
146 findings.extend(duplicate_purpose_findings(nodes));
147 findings.extend(temp_folder_findings(nodes));
148 findings
149}
150
151#[must_use]
153pub fn unresolved_health_findings(
154 findings: Vec<HealthFinding>,
155 resolved_ids: &[String],
156) -> Vec<HealthFinding> {
157 findings
158 .into_iter()
159 .filter(|finding| !resolved_ids.iter().any(|id| id == &finding.id))
160 .collect()
161}
162
163#[must_use]
168pub fn finding_id(category: &str, path: &str, related_path: Option<&str>) -> String {
169 let related_path = related_path.unwrap_or("");
170 format!("{category}:{path}:{related_path}")
171}
172
173fn missing_purpose_findings(nodes: &[IndexedNode]) -> Vec<HealthFinding> {
175 nodes
176 .iter()
177 .filter(|node| node.purpose.status == PurposeStatus::Missing)
178 .map(|node| HealthFinding {
179 id: finding_id(CATEGORY_MISSING_PURPOSE, &node.node.path, None),
180 severity: Severity::Warning,
181 category: CATEGORY_MISSING_PURPOSE.to_string(),
182 path: node.node.path.clone(),
183 related_path: None,
184 message: MESSAGE_MISSING_PURPOSE.to_string(),
185 recommendation: RECOMMENDATION_MISSING_PURPOSE.to_string(),
186 })
187 .collect()
188}
189
190fn suggested_purpose_findings(nodes: &[IndexedNode]) -> Vec<HealthFinding> {
192 nodes
193 .iter()
194 .filter(|node| node.purpose.status == PurposeStatus::Suggested)
195 .map(|node| HealthFinding {
196 id: finding_id(CATEGORY_SUGGESTED_PURPOSE_REVIEW, &node.node.path, None),
197 severity: Severity::Warning,
198 category: CATEGORY_SUGGESTED_PURPOSE_REVIEW.to_string(),
199 path: node.node.path.clone(),
200 related_path: None,
201 message: MESSAGE_SUGGESTED_PURPOSE_REVIEW.to_string(),
202 recommendation: RECOMMENDATION_SUGGESTED_PURPOSE_REVIEW.to_string(),
203 })
204 .collect()
205}
206
207fn stale_purpose_findings(nodes: &[IndexedNode]) -> Vec<HealthFinding> {
209 nodes
210 .iter()
211 .filter(|node| node.purpose.status == PurposeStatus::Stale)
212 .map(|node| HealthFinding {
213 id: finding_id(CATEGORY_STALE_PURPOSE, &node.node.path, None),
214 severity: Severity::Warning,
215 category: CATEGORY_STALE_PURPOSE.to_string(),
216 path: node.node.path.clone(),
217 related_path: None,
218 message: MESSAGE_STALE_PURPOSE.to_string(),
219 recommendation: RECOMMENDATION_STALE_PURPOSE.to_string(),
220 })
221 .collect()
222}
223
224fn agent_review_required_findings(nodes: &[IndexedNode]) -> Vec<HealthFinding> {
226 nodes
227 .iter()
228 .filter(|node| node.purpose.status == PurposeStatus::Approved)
229 .filter(|node| !node.purpose.agent_reviewed())
230 .filter(|node| {
231 node.node.kind == NodeKind::Folder
232 || (node.node.kind == NodeKind::File && is_high_impact_file_path(&node.node.path))
233 })
234 .map(|node| HealthFinding {
235 id: finding_id(
236 CATEGORY_PURPOSE_AGENT_REVIEW_REQUIRED,
237 &node.node.path,
238 None,
239 ),
240 severity: Severity::Warning,
241 category: CATEGORY_PURPOSE_AGENT_REVIEW_REQUIRED.to_string(),
242 path: node.node.path.clone(),
243 related_path: None,
244 message: MESSAGE_PURPOSE_AGENT_REVIEW_REQUIRED.to_string(),
245 recommendation: RECOMMENDATION_PURPOSE_AGENT_REVIEW_REQUIRED.to_string(),
246 })
247 .collect()
248}
249
250fn duplicate_purpose_findings(nodes: &[IndexedNode]) -> Vec<HealthFinding> {
252 let mut by_purpose: HashMap<(NodeKind, String, String), Vec<&IndexedNode>> = HashMap::new();
253 for node in nodes {
254 if node.purpose.status != PurposeStatus::Approved {
255 continue;
256 }
257 let Some(purpose) = &node.purpose.purpose else {
258 continue;
259 };
260 by_purpose
261 .entry((
262 node.node.kind,
263 purpose.to_lowercase(),
264 duplicate_context_key(node),
265 ))
266 .or_default()
267 .push(node);
268 }
269 let mut findings = Vec::new();
270 for ((kind, _, _), matches) in by_purpose {
271 if matches.len() < 2 {
272 continue;
273 }
274 let first = matches[0];
275 for duplicate in matches.iter().skip(1) {
276 findings.push(HealthFinding {
277 id: finding_id(
278 CATEGORY_DUPLICATE_PURPOSE,
279 &duplicate.node.path,
280 Some(&first.node.path),
281 ),
282 severity: Severity::Warning,
283 category: CATEGORY_DUPLICATE_PURPOSE.to_string(),
284 path: duplicate.node.path.clone(),
285 related_path: Some(first.node.path.clone()),
286 message: format!("Multiple {kind} nodes share the same purpose."),
287 recommendation: RECOMMENDATION_DUPLICATE_PURPOSE.to_string(),
288 });
289 }
290 }
291 findings
292}
293
294fn duplicate_context_key(node: &IndexedNode) -> String {
296 if node.node.kind == NodeKind::Folder {
297 normalized_parent(&node.node.path).unwrap_or_default()
298 } else {
299 String::new()
300 }
301}
302
303fn temp_folder_findings(nodes: &[IndexedNode]) -> Vec<HealthFinding> {
305 let mut buckets: HashMap<&str, Vec<&IndexedNode>> = HashMap::new();
306 for node in nodes
307 .iter()
308 .filter(|node| node.node.kind == NodeKind::Folder)
309 {
310 let Some(name) = node.node.path.rsplit('/').next() else {
311 continue;
312 };
313 let normalized = name.to_lowercase();
314 if let Some(bucket) = TEMP_FOLDER_BUCKETS
315 .iter()
316 .find(|candidate| **candidate == normalized)
317 {
318 buckets.entry(bucket).or_default().push(node);
319 }
320 }
321 let mut findings = Vec::new();
322 for (bucket, matches) in buckets {
323 if matches.len() < 2 {
324 continue;
325 }
326 let first = matches[0];
327 for duplicate in matches.iter().skip(1) {
328 findings.push(HealthFinding {
329 id: finding_id(
330 CATEGORY_REPEATED_TEMPORARY_FOLDER,
331 &duplicate.node.path,
332 Some(&first.node.path),
333 ),
334 severity: Severity::Warning,
335 category: CATEGORY_REPEATED_TEMPORARY_FOLDER.to_string(),
336 path: duplicate.node.path.clone(),
337 related_path: Some(first.node.path.clone()),
338 message: format!("Repeated temporary/generated folder name `{bucket}` found."),
339 recommendation: RECOMMENDATION_REPEATED_TEMPORARY_FOLDER.to_string(),
340 });
341 }
342 }
343 findings
344}
345
346#[cfg(test)]
347mod tests {
348 use super::*;
349 use crate::{IndexedNode, Node, Purpose, PurposeSource};
350 use std::error::Error;
351
352 #[test]
353 fn suggested_purpose_requires_review_and_is_not_duplicate_signal() -> Result<(), Box<dyn Error>>
354 {
355 let nodes = vec![
356 test_node(
357 "src/a.rs",
358 NodeKind::File,
359 Some("Generated file purpose"),
360 PurposeStatus::Suggested,
361 ),
362 test_node(
363 "src/b.rs",
364 NodeKind::File,
365 Some("Generated file purpose"),
366 PurposeStatus::Suggested,
367 ),
368 ];
369
370 let findings = health_check(&nodes);
371 require_category(&findings, CATEGORY_SUGGESTED_PURPOSE_REVIEW)?;
372 reject_category(&findings, CATEGORY_DUPLICATE_PURPOSE)?;
373 Ok(())
374 }
375
376 #[test]
377 fn approved_navigation_purposes_require_agent_review() -> Result<(), Box<dyn Error>> {
378 let nodes = vec![
379 test_node_with_source(
380 ".",
381 NodeKind::Folder,
382 Some("Imported repository root"),
383 PurposeStatus::Approved,
384 PurposeSource::Imported,
385 ),
386 test_node_with_source(
387 "Cargo.toml",
388 NodeKind::File,
389 Some("Imported Rust manifest"),
390 PurposeStatus::Approved,
391 PurposeSource::Imported,
392 ),
393 test_node_with_source(
394 "src/detail.rs",
395 NodeKind::File,
396 Some("Imported implementation detail"),
397 PurposeStatus::Approved,
398 PurposeSource::Imported,
399 ),
400 ];
401
402 let findings = health_check(&nodes);
403 require_category(&findings, CATEGORY_PURPOSE_AGENT_REVIEW_REQUIRED)?;
404 require_path(&findings, CATEGORY_PURPOSE_AGENT_REVIEW_REQUIRED, ".")?;
405 require_path(
406 &findings,
407 CATEGORY_PURPOSE_AGENT_REVIEW_REQUIRED,
408 "Cargo.toml",
409 )?;
410 reject_path(
411 &findings,
412 CATEGORY_PURPOSE_AGENT_REVIEW_REQUIRED,
413 "src/detail.rs",
414 )?;
415 Ok(())
416 }
417
418 #[test]
419 fn duplicate_purpose_uses_approved_purposes_only() -> Result<(), Box<dyn Error>> {
420 let nodes = vec![
421 test_node(
422 "src/a.rs",
423 NodeKind::File,
424 Some("Approved duplicate purpose"),
425 PurposeStatus::Approved,
426 ),
427 test_node(
428 "src/b.rs",
429 NodeKind::File,
430 Some("Approved duplicate purpose"),
431 PurposeStatus::Approved,
432 ),
433 ];
434
435 let findings = health_check(&nodes);
436 require_category(&findings, CATEGORY_DUPLICATE_PURPOSE)?;
437 reject_category(&findings, CATEGORY_SUGGESTED_PURPOSE_REVIEW)?;
438 Ok(())
439 }
440
441 #[test]
442 fn duplicate_folder_purpose_is_scoped_by_parent_context() -> Result<(), Box<dyn Error>> {
443 let nodes = vec![
444 test_node(
445 "customers/service",
446 NodeKind::Folder,
447 Some("Service layer"),
448 PurposeStatus::Approved,
449 ),
450 test_node(
451 "settings/service",
452 NodeKind::Folder,
453 Some("Service layer"),
454 PurposeStatus::Approved,
455 ),
456 ];
457
458 let findings = health_check(&nodes);
459 reject_category(&findings, CATEGORY_DUPLICATE_PURPOSE)?;
460 Ok(())
461 }
462
463 fn test_node(
465 path: &str,
466 kind: NodeKind,
467 purpose: Option<&str>,
468 status: PurposeStatus,
469 ) -> IndexedNode {
470 let source = if status == PurposeStatus::Suggested {
471 PurposeSource::Generated
472 } else {
473 PurposeSource::Agent
474 };
475 test_node_with_source(path, kind, purpose, status, source)
476 }
477
478 fn test_node_with_source(
480 path: &str,
481 kind: NodeKind,
482 purpose: Option<&str>,
483 status: PurposeStatus,
484 source: PurposeSource,
485 ) -> IndexedNode {
486 IndexedNode {
487 node: Node {
488 path: path.to_string(),
489 kind,
490 parent_path: normalized_parent(path),
491 extension: Some(".rs".to_string()),
492 language: Some("rust".to_string()),
493 size_bytes: Some(10),
494 mtime_ns: Some(0),
495 content_hash: Some("hash".to_string()),
496 },
497 purpose: Purpose {
498 path: path.to_string(),
499 purpose: purpose.map(str::to_string),
500 source,
501 status,
502 },
503 summary: Some("rust source summary".to_string()),
504 }
505 }
506
507 fn require_category(findings: &[HealthFinding], category: &str) -> Result<(), Box<dyn Error>> {
509 if findings.iter().any(|finding| finding.category == category) {
510 Ok(())
511 } else {
512 Err(std::io::Error::other(format!("missing category {category}")).into())
513 }
514 }
515
516 fn reject_category(findings: &[HealthFinding], category: &str) -> Result<(), Box<dyn Error>> {
518 if findings.iter().any(|finding| finding.category == category) {
519 Err(std::io::Error::other(format!("unexpected category {category}")).into())
520 } else {
521 Ok(())
522 }
523 }
524
525 fn require_path(
527 findings: &[HealthFinding],
528 category: &str,
529 path: &str,
530 ) -> Result<(), Box<dyn Error>> {
531 if findings
532 .iter()
533 .any(|finding| finding.category == category && finding.path == path)
534 {
535 Ok(())
536 } else {
537 Err(std::io::Error::other(format!("missing category {category} path {path}")).into())
538 }
539 }
540
541 fn reject_path(
543 findings: &[HealthFinding],
544 category: &str,
545 path: &str,
546 ) -> Result<(), Box<dyn Error>> {
547 if findings
548 .iter()
549 .any(|finding| finding.category == category && finding.path == path)
550 {
551 Err(std::io::Error::other(format!("unexpected category {category} path {path}")).into())
552 } else {
553 Ok(())
554 }
555 }
556}