Skip to main content

nodex_core/query/
detect.rs

1use chrono::{Local, NaiveDate};
2
3use crate::config::Config;
4use crate::model::Graph;
5
6#[derive(Debug, serde::Serialize)]
7pub struct OrphanEntry {
8    pub id: String,
9    pub title: String,
10    pub kind: String,
11    pub path: String,
12    pub created: Option<NaiveDate>,
13}
14
15/// Find nodes with zero incoming edges (orphans).
16pub fn find_orphans(graph: &Graph, config: &Config) -> Vec<OrphanEntry> {
17    let today = Local::now().date_naive();
18    // User-supplied u32 — checked subtraction prevents DoS via
19    // `orphan_grace_days = u32::MAX`. On underflow we behave as if
20    // the grace window swallows every doc (no orphans exist inside
21    // it), which is the conservative answer.
22    let Some(grace_cutoff) = today.checked_sub_days(chrono::Days::new(u64::from(
23        config.detection.orphan_grace_days,
24    ))) else {
25        return Vec::new();
26    };
27
28    let mut orphans: Vec<OrphanEntry> = graph
29        .nodes()
30        .values()
31        .filter(|node| {
32            // Skip kinds declared leaf-by-design at config level.
33            if config.is_orphan_ok_kind(node.kind.as_str()) {
34                return false;
35            }
36
37            // Skip nodes explicitly marked as ok
38            if node.orphan_ok {
39                return false;
40            }
41
42            // Skip nodes with incoming edges
43            if !graph.incoming_indices(&node.id).is_empty() {
44                return false;
45            }
46
47            // Skip nodes within grace period
48            if let Some(created) = node.created
49                && created > grace_cutoff
50            {
51                return false;
52            }
53
54            true
55        })
56        .map(|node| OrphanEntry {
57            id: node.id.clone(),
58            title: node.title.clone(),
59            kind: node.kind.to_string(),
60            path: node.path.to_string_lossy().to_string(),
61            created: node.created,
62        })
63        .collect();
64
65    orphans.sort_by(|a, b| a.id.cmp(&b.id));
66    orphans
67}
68
69#[derive(Debug, serde::Serialize)]
70pub struct StaleEntry {
71    pub id: String,
72    pub title: String,
73    pub path: String,
74    pub reviewed: NaiveDate,
75    pub days_since: i64,
76}
77
78/// Find active documents that haven't been reviewed within the threshold.
79pub fn find_stale(graph: &Graph, config: &Config) -> Vec<StaleEntry> {
80    let today = Local::now().date_naive();
81    // Same DoS guard as `find_orphans` / `StaleReviewRule`.
82    let Some(cutoff) =
83        today.checked_sub_days(chrono::Days::new(u64::from(config.detection.stale_days)))
84    else {
85        return Vec::new();
86    };
87
88    let mut stale: Vec<StaleEntry> = graph
89        .nodes()
90        .values()
91        .filter(|node| {
92            // Only active nodes
93            if config.is_terminal(node.status.as_str()) {
94                return false;
95            }
96
97            // Must have a reviewed date that's older than cutoff
98            match node.reviewed {
99                Some(reviewed) => reviewed < cutoff,
100                None => false, // No reviewed date = not trackable, not stale
101            }
102        })
103        .filter_map(|node| {
104            let reviewed = node.reviewed?; // safe: filter above ensures Some
105            Some(StaleEntry {
106                id: node.id.clone(),
107                title: node.title.clone(),
108                path: node.path.to_string_lossy().to_string(),
109                reviewed,
110                days_since: (today - reviewed).num_days(),
111            })
112        })
113        .collect();
114
115    stale.sort_by(|a, b| a.reviewed.cmp(&b.reviewed).then_with(|| a.id.cmp(&b.id)));
116    stale
117}