projectatlas_core/
health.rs

1//! Purpose: Detect structural health issues in `ProjectAtlas` indexes.
2
3use crate::{IndexedNode, NodeKind, PurposeStatus, is_high_impact_file_path, normalized_parent};
4use serde::{Deserialize, Serialize};
5use std::{collections::HashMap, fmt, str::FromStr};
6
7/// Health category for paths without purpose metadata.
8pub const CATEGORY_MISSING_PURPOSE: &str = "missing-purpose";
9/// Health category for generated purpose suggestions awaiting review.
10pub const CATEGORY_SUGGESTED_PURPOSE_REVIEW: &str = "suggested-purpose-review";
11/// Health category for legacy or explicitly flagged accepted-purpose records.
12pub const CATEGORY_STALE_PURPOSE: &str = "stale-purpose";
13/// Health category for approved purposes that still need agent review.
14pub const CATEGORY_PURPOSE_AGENT_REVIEW_REQUIRED: &str = "purpose-agent-review-required";
15/// Health category for duplicated purpose text.
16pub const CATEGORY_DUPLICATE_PURPOSE: &str = "duplicate-purpose";
17/// Health category for repeated temporary/generated-output folders.
18pub const CATEGORY_REPEATED_TEMPORARY_FOLDER: &str = "repeated-temporary-folder";
19/// Structural categories that are not simple purpose lifecycle states.
20pub const STRUCTURAL_HEALTH_CATEGORIES: [&str; 3] = [
21    CATEGORY_PURPOSE_AGENT_REVIEW_REQUIRED,
22    CATEGORY_DUPLICATE_PURPOSE,
23    CATEGORY_REPEATED_TEMPORARY_FOLDER,
24];
25/// Folder names treated as repeated temporary/generated-output buckets.
26pub const TEMP_FOLDER_BUCKETS: [&str; 6] = ["tmp", "temp", "cache", "generated", "out", "output"];
27
28/// Finding message for missing-purpose rows.
29pub const MESSAGE_MISSING_PURPOSE: &str = "Path is indexed but has no approved purpose.";
30/// Finding recommendation for missing-purpose rows.
31pub const RECOMMENDATION_MISSING_PURPOSE: &str =
32    "Set or approve a one-line purpose in the ProjectAtlas index.";
33/// Queue recommendation for missing-purpose rows.
34pub const RECOMMENDATION_MISSING_PURPOSE_QUEUE: &str =
35    "Set an agent-reviewed one-line purpose in the ProjectAtlas index.";
36/// Finding message for suggested-purpose-review rows.
37pub const MESSAGE_SUGGESTED_PURPOSE_REVIEW: &str =
38    "Path has a generated purpose suggestion but no agent-approved purpose.";
39/// Finding recommendation for suggested-purpose-review rows.
40pub const RECOMMENDATION_SUGGESTED_PURPOSE_REVIEW: &str =
41    "Inspect the folder/file summary and approve or correct the purpose in SQLite.";
42/// Queue recommendation for suggested-purpose-review rows.
43pub const RECOMMENDATION_SUGGESTED_PURPOSE_REVIEW_QUEUE: &str =
44    "Inspect enough context and approve or correct the purpose in SQLite.";
45/// Finding message for stale-purpose rows.
46pub const MESSAGE_STALE_PURPOSE: &str =
47    "Accepted purpose is in a legacy or explicitly flagged review state.";
48/// Finding recommendation for stale-purpose rows.
49pub const RECOMMENDATION_STALE_PURPOSE: &str =
50    "Explicitly approve the existing responsibility or correct it if inconsistent.";
51/// Finding message for purpose-agent-review-required rows.
52pub const MESSAGE_PURPOSE_AGENT_REVIEW_REQUIRED: &str =
53    "Purpose is approved but has not been reviewed by an agent.";
54/// Finding recommendation for purpose-agent-review-required rows.
55pub const RECOMMENDATION_PURPOSE_AGENT_REVIEW_REQUIRED: &str =
56    "Inspect current context and approve or correct the purpose with purpose set.";
57/// Finding recommendation for duplicate-purpose rows.
58pub const RECOMMENDATION_DUPLICATE_PURPOSE: &str =
59    "Review whether these paths duplicate responsibility or need clearer purposes.";
60/// Finding recommendation for repeated-temporary-folder rows.
61pub const RECOMMENDATION_REPEATED_TEMPORARY_FOLDER: &str =
62    "Consolidate temporary/generated output roots or add an allowlist rationale.";
63
64/// Health finding severity.
65#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
66#[serde(rename_all = "lowercase")]
67pub enum Severity {
68    /// Informational finding.
69    Info,
70    /// Warning finding.
71    Warning,
72    /// Error finding.
73    Error,
74}
75
76impl Severity {
77    /// Return the stable lowercase database and payload value.
78    #[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/// Error returned when a health severity string is unknown.
95#[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/// Health finding emitted by `ProjectAtlas`.
120#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
121pub struct HealthFinding {
122    /// Stable finding id derived from category and affected paths.
123    pub id: String,
124    /// Finding severity.
125    pub severity: Severity,
126    /// Finding category.
127    pub category: String,
128    /// Primary path.
129    pub path: String,
130    /// Related path when applicable.
131    pub related_path: Option<String>,
132    /// Human-readable message.
133    pub message: String,
134    /// Recommended cleanup or review action.
135    pub recommendation: String,
136}
137
138/// Run initial structural health checks.
139#[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/// Return findings that have not been marked resolved.
152#[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/// Build a stable finding id from category and affected paths.
164///
165/// The database layer uses the same id contract when it builds health
166/// findings through SQL instead of materializing the complete node list.
167#[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
173/// Find indexed paths without purpose metadata.
174fn 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
190/// Find indexed paths with generated purpose suggestions that need agent review.
191fn 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
207/// Find legacy or explicitly flagged accepted purposes awaiting explicit review.
208fn 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
224/// Find navigation-critical approved purposes that still need agent review.
225fn 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
250/// Find paths that share the same purpose text.
251fn 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
294/// Return the duplicate-purpose comparison context for a node.
295fn 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
303/// Find repeated temporary or generated-output folders.
304fn 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    /// Build a health-check test node.
464    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    /// Build a health-check test node with an explicit purpose source.
479    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    /// Require a health finding category to be present.
508    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    /// Require a health finding category to be absent.
517    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    /// Require a health finding category/path pair to be present.
526    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    /// Require a health finding category/path pair to be absent.
542    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}