Skip to main content

rch_common/
closure_explain.rs

1//! Path-dependency closure sync explanation (bd-session-history-remediation-ocv9i.8.1).
2//!
3//! Backs `rch sync --explain` / the doctor surface: for every root in a
4//! [`DependencyClosurePlan`], it joins the *planner* facts (why a root is
5//! included, its risk class, sync order) with *convergence* facts (local vs
6//! remote revision/hash, last sync time) into a [`ClosureExplainReport`] so an
7//! operator sees the whole closure — and why each root will (or won't) sync —
8//! without inspecting Cargo manifests, the daemon, and the remote separately.
9//!
10//! Convergence facts are supplied by the caller (the daemon's repo-convergence
11//! layer), so this stays a pure, deterministic join with no I/O.
12
13use std::collections::BTreeMap;
14use std::path::PathBuf;
15
16use serde::{Deserialize, Serialize};
17
18use crate::dependency_closure_planner::{
19    DependencyClosurePlan, DependencyRiskClass, DependencySyncReason,
20};
21
22/// Per-root convergence facts (local vs remote state), keyed into the explain
23/// join by canonical root path.
24#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
25pub struct RootConvergence {
26    /// Whether the root exists locally at all.
27    pub present: bool,
28    #[serde(default, skip_serializing_if = "Option::is_none")]
29    pub local_revision: Option<String>,
30    #[serde(default, skip_serializing_if = "Option::is_none")]
31    pub local_hash: Option<String>,
32    #[serde(default, skip_serializing_if = "Option::is_none")]
33    pub remote_revision: Option<String>,
34    #[serde(default, skip_serializing_if = "Option::is_none")]
35    pub remote_hash: Option<String>,
36    /// Last successful sync time (Unix ms), if ever synced.
37    #[serde(default, skip_serializing_if = "Option::is_none")]
38    pub last_sync_unix_ms: Option<u64>,
39}
40
41/// The effective sync action for one root, derived from convergence.
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
43#[serde(rename_all = "snake_case")]
44pub enum RootSyncOutcome {
45    /// Local root is missing — cannot sync.
46    MissingRoot,
47    /// Never synced to the remote (no remote hash yet) — first sync needed.
48    SyncNeeded,
49    /// Remote is out of date relative to local — re-sync needed.
50    StaleRemote,
51    /// Local and remote agree — nothing to do.
52    NoOpClean,
53}
54
55impl RootSyncOutcome {
56    /// Whether this outcome implies a transfer will occur.
57    #[must_use]
58    pub const fn needs_sync(self) -> bool {
59        matches!(self, Self::SyncNeeded | Self::StaleRemote)
60    }
61}
62
63/// Derive the sync outcome for a root from its convergence facts.
64#[must_use]
65pub fn derive_outcome(c: &RootConvergence) -> RootSyncOutcome {
66    if !c.present {
67        return RootSyncOutcome::MissingRoot;
68    }
69    match (&c.local_hash, &c.remote_hash) {
70        (_, None) => RootSyncOutcome::SyncNeeded,
71        (Some(l), Some(r)) if l == r => RootSyncOutcome::NoOpClean,
72        _ => RootSyncOutcome::StaleRemote,
73    }
74}
75
76/// One explained root: planner facts joined with convergence facts.
77#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
78pub struct ClosureExplainEntry {
79    pub order_index: usize,
80    pub package_root: PathBuf,
81    pub package_name: String,
82    /// Why the root is in the closure.
83    pub included_reason: DependencySyncReason,
84    /// Planner risk class.
85    pub risk: DependencyRiskClass,
86    pub convergence: RootConvergence,
87    /// Effective sync action derived from convergence.
88    pub sync_action: RootSyncOutcome,
89}
90
91/// The full explanation report for a closure plan.
92#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
93pub struct ClosureExplainReport {
94    pub entry_manifest_path: PathBuf,
95    /// True when the closure planner failed open (degraded to a safe fallback).
96    pub fail_open: bool,
97    pub entries: Vec<ClosureExplainEntry>,
98    /// Count of roots that will actually transfer.
99    pub sync_count: usize,
100    /// Count of roots missing locally.
101    pub missing_count: usize,
102}
103
104impl ClosureExplainReport {
105    /// True when nothing needs to sync and nothing is missing — a clean no-op.
106    #[must_use]
107    pub fn is_clean_noop(&self) -> bool {
108        self.sync_count == 0 && self.missing_count == 0
109    }
110
111    /// Human-readable explanation block.
112    #[must_use]
113    pub fn render(&self) -> String {
114        let mut s = format!(
115            "path-dependency closure ({} roots, {} to sync, {} missing{}):",
116            self.entries.len(),
117            self.sync_count,
118            self.missing_count,
119            if self.fail_open { ", FAIL-OPEN" } else { "" },
120        );
121        for e in &self.entries {
122            s.push_str(&format!(
123                "\n  [{}] {} ({:?}, risk={:?}) -> {:?}",
124                e.order_index, e.package_name, e.included_reason, e.risk, e.sync_action,
125            ));
126            s.push_str(&format!(
127                "\n      local rev={} hash={} | remote rev={} hash={} | last_sync_ms={}",
128                opt(&e.convergence.local_revision),
129                opt(&e.convergence.local_hash),
130                opt(&e.convergence.remote_revision),
131                opt(&e.convergence.remote_hash),
132                e.convergence
133                    .last_sync_unix_ms
134                    .map_or_else(|| "never".to_string(), |v| v.to_string()),
135            ));
136        }
137        if self.is_clean_noop() {
138            s.push_str("\n  -> clean: nothing to sync");
139        }
140        s
141    }
142}
143
144fn opt(o: &Option<String>) -> &str {
145    o.as_deref().unwrap_or("-")
146}
147
148/// Build the explanation report by joining a closure plan with per-root
149/// convergence facts (keyed by canonical root path). Roots with no convergence
150/// entry are treated as missing.
151#[must_use]
152pub fn explain_closure(
153    plan: &DependencyClosurePlan,
154    convergence: &BTreeMap<PathBuf, RootConvergence>,
155) -> ClosureExplainReport {
156    let mut entries = Vec::with_capacity(plan.sync_order.len());
157    let mut sync_count = 0;
158    let mut missing_count = 0;
159
160    for action in &plan.sync_order {
161        let conv = convergence
162            .get(&action.package_root)
163            .cloned()
164            .unwrap_or_default();
165        let outcome = derive_outcome(&conv);
166        match outcome {
167            RootSyncOutcome::MissingRoot => missing_count += 1,
168            o if o.needs_sync() => sync_count += 1,
169            _ => {}
170        }
171        entries.push(ClosureExplainEntry {
172            order_index: action.order_index,
173            package_root: action.package_root.clone(),
174            package_name: action.package_name.clone(),
175            included_reason: action.metadata.reason,
176            risk: action.risk,
177            convergence: conv,
178            sync_action: outcome,
179        });
180    }
181
182    ClosureExplainReport {
183        entry_manifest_path: plan.entry_manifest_path.clone(),
184        fail_open: plan.fail_open,
185        entries,
186        sync_count,
187        missing_count,
188    }
189}
190
191#[cfg(test)]
192mod tests {
193    use super::*;
194    use crate::dependency_closure_planner::{
195        DependencyClosurePlanState, DependencySyncAction, DependencySyncMetadata,
196    };
197
198    fn action(
199        idx: usize,
200        root: &str,
201        name: &str,
202        reason: DependencySyncReason,
203    ) -> DependencySyncAction {
204        DependencySyncAction {
205            order_index: idx,
206            package_root: PathBuf::from(root),
207            manifest_path: PathBuf::from(format!("{root}/Cargo.toml")),
208            package_name: name.to_string(),
209            risk: DependencyRiskClass::Low,
210            metadata: DependencySyncMetadata {
211                reason,
212                workspace_member: reason == DependencySyncReason::WorkspaceMember,
213                root_package: reason == DependencySyncReason::EntryPoint,
214                inbound_dependency_names: Vec::new(),
215                dependent_roots: Vec::new(),
216                notes: Vec::new(),
217            },
218        }
219    }
220
221    fn plan(actions: Vec<DependencySyncAction>) -> DependencyClosurePlan {
222        DependencyClosurePlan {
223            state: DependencyClosurePlanState::Ready,
224            entry_manifest_path: PathBuf::from("/proj/Cargo.toml"),
225            workspace_root: Some(PathBuf::from("/proj")),
226            canonical_roots: actions.iter().map(|a| a.package_root.clone()).collect(),
227            sync_order: actions,
228            fail_open: false,
229            fail_open_reason: None,
230            issues: Vec::new(),
231        }
232    }
233
234    fn converged(local: &str, remote: &str) -> RootConvergence {
235        RootConvergence {
236            present: true,
237            local_revision: Some("rev".to_string()),
238            local_hash: Some(local.to_string()),
239            remote_revision: Some("rev".to_string()),
240            remote_hash: Some(remote.to_string()),
241            last_sync_unix_ms: Some(1_700_000_000_000),
242        }
243    }
244
245    #[test]
246    fn derive_outcome_covers_all_states() {
247        assert_eq!(
248            derive_outcome(&RootConvergence::default()),
249            RootSyncOutcome::MissingRoot
250        );
251        assert_eq!(
252            derive_outcome(&converged("h", "h")),
253            RootSyncOutcome::NoOpClean
254        );
255        assert_eq!(
256            derive_outcome(&converged("h1", "h2")),
257            RootSyncOutcome::StaleRemote
258        );
259        let never = RootConvergence {
260            present: true,
261            local_hash: Some("h".to_string()),
262            remote_hash: None,
263            ..RootConvergence::default()
264        };
265        assert_eq!(derive_outcome(&never), RootSyncOutcome::SyncNeeded);
266    }
267
268    #[test]
269    fn multi_crate_clean_no_op() {
270        let p = plan(vec![
271            action(0, "/proj/a", "a", DependencySyncReason::EntryPoint),
272            action(1, "/proj/b", "b", DependencySyncReason::WorkspaceMember),
273        ]);
274        let mut conv = BTreeMap::new();
275        conv.insert(PathBuf::from("/proj/a"), converged("h", "h"));
276        conv.insert(PathBuf::from("/proj/b"), converged("h", "h"));
277        let report = explain_closure(&p, &conv);
278        assert_eq!(report.entries.len(), 2);
279        assert!(report.is_clean_noop());
280        assert_eq!(report.sync_count, 0);
281        assert!(
282            report
283                .entries
284                .iter()
285                .all(|e| e.sync_action == RootSyncOutcome::NoOpClean)
286        );
287    }
288
289    #[test]
290    fn sibling_repo_transitive_dependency_is_explained() {
291        // A path dep in a sibling repo (outside the workspace root).
292        let p = plan(vec![
293            action(0, "/proj/main", "main", DependencySyncReason::EntryPoint),
294            action(
295                1,
296                "/sibling/lib",
297                "lib",
298                DependencySyncReason::TransitivePathDependency,
299            ),
300        ]);
301        let mut conv = BTreeMap::new();
302        conv.insert(PathBuf::from("/proj/main"), converged("h", "h"));
303        conv.insert(PathBuf::from("/sibling/lib"), converged("h", "h"));
304        let report = explain_closure(&p, &conv);
305        let sib = report
306            .entries
307            .iter()
308            .find(|e| e.package_name == "lib")
309            .unwrap();
310        assert_eq!(
311            sib.included_reason,
312            DependencySyncReason::TransitivePathDependency
313        );
314    }
315
316    #[test]
317    fn stale_remote_root_needs_sync() {
318        let p = plan(vec![action(
319            0,
320            "/proj/a",
321            "a",
322            DependencySyncReason::EntryPoint,
323        )]);
324        let mut conv = BTreeMap::new();
325        conv.insert(
326            PathBuf::from("/proj/a"),
327            converged("localhash", "OLDremote"),
328        );
329        let report = explain_closure(&p, &conv);
330        assert_eq!(report.entries[0].sync_action, RootSyncOutcome::StaleRemote);
331        assert_eq!(report.sync_count, 1);
332        assert!(!report.is_clean_noop());
333    }
334
335    #[test]
336    fn missing_root_is_flagged() {
337        let p = plan(vec![action(
338            0,
339            "/proj/gone",
340            "gone",
341            DependencySyncReason::TransitivePathDependency,
342        )]);
343        // No convergence entry -> missing.
344        let report = explain_closure(&p, &BTreeMap::new());
345        assert_eq!(report.entries[0].sync_action, RootSyncOutcome::MissingRoot);
346        assert_eq!(report.missing_count, 1);
347        assert!(!report.is_clean_noop());
348    }
349
350    #[test]
351    fn render_and_serde_roundtrip() {
352        let p = plan(vec![action(
353            0,
354            "/proj/a",
355            "a",
356            DependencySyncReason::EntryPoint,
357        )]);
358        let mut conv = BTreeMap::new();
359        conv.insert(PathBuf::from("/proj/a"), converged("h", "h"));
360        let report = explain_closure(&p, &conv);
361        let text = report.render();
362        assert!(text.contains("path-dependency closure"));
363        assert!(text.contains("clean: nothing to sync"));
364        let back: ClosureExplainReport =
365            serde_json::from_str(&serde_json::to_string(&report).unwrap()).unwrap();
366        assert_eq!(report, back);
367    }
368
369    #[test]
370    fn fail_open_plan_is_marked() {
371        let mut p = plan(vec![action(
372            0,
373            "/proj/a",
374            "a",
375            DependencySyncReason::EntryPoint,
376        )]);
377        p.fail_open = true;
378        let report = explain_closure(&p, &BTreeMap::new());
379        assert!(report.fail_open);
380        assert!(report.render().contains("FAIL-OPEN"));
381    }
382}