Skip to main content

dev_prune/commands/
trust.rs

1// Copyright 2026 VKrishna04
2// SPDX-License-Identifier: Apache-2.0
3
4// Handler for `dev-prune trust`.
5//
6// A tool that deletes directories on a schedule has to answer "what exactly is this
7// allowed to do on my machine?", and until this command existed the honest answer was
8// "read four documentation pages and three `devp config` keys". This prints it.
9//
10// Two kinds of row, and the distinction is the whole point:
11//
12//   - **Guarantees** are structural. They come from the code paths in `engine.rs`, they
13//     have no setting and no flag, and a build where one of them did not hold would be a
14//     bug rather than a configuration. They read the same on every machine.
15//   - **This machine** is read live — the scheduler, the Git hooks, the settings that
16//     widen what dev-prune may do. These differ per machine and are the reason the
17//     command exists at all.
18//
19// Nothing here is a self-assessment. Every "this machine" row is a value read back from
20// the registry or the OS, and the three rows that can lower the verdict are named
21// individually so the report cannot congratulate itself in the abstract.
22
23use anyhow::Result;
24
25use crate::commands::hook::{self, HookState};
26use crate::config::Registry;
27use crate::constants;
28use crate::daemon;
29use crate::json;
30use crate::output;
31
32/// What one row says about the state it reports.
33#[derive(Clone, Copy, PartialEq, Eq)]
34pub enum Verdict {
35    /// Structural, with no setting and no flag that changes it.
36    Guaranteed,
37    /// True on this machine, and the safe answer.
38    Safe,
39    /// True on this machine, and something the reader should know about. Never a
40    /// failure — every one of these is a choice someone made deliberately.
41    Widened,
42    /// Neither safe nor widened: a fact worth printing, like where the config lives.
43    Neutral,
44}
45
46impl Verdict {
47    /// The glyph in front of the row.
48    fn mark(self) -> &'static str {
49        match self {
50            Verdict::Guaranteed | Verdict::Safe => "+",
51            Verdict::Widened => "!",
52            Verdict::Neutral => " ",
53        }
54    }
55
56    /// The word `--json` uses. Separate from the prose so rewording a row never breaks
57    /// a script reading the document.
58    fn key(self) -> &'static str {
59        match self {
60            Verdict::Guaranteed => "guaranteed",
61            Verdict::Safe => "safe",
62            Verdict::Widened => "widened",
63            Verdict::Neutral => "neutral",
64        }
65    }
66}
67
68/// One line of the report.
69pub struct TrustRow {
70    /// Stable identifier for `--json`.
71    pub key: &'static str,
72    /// What is being reported, for a human.
73    pub subject: &'static str,
74    /// The state it is in, in the user's terms.
75    pub state: String,
76    /// How to read that state.
77    pub verdict: Verdict,
78}
79
80impl TrustRow {
81    fn new(
82        key: &'static str,
83        subject: &'static str,
84        state: impl Into<String>,
85        verdict: Verdict,
86    ) -> Self {
87        Self {
88            key,
89            subject,
90            state: state.into(),
91            verdict,
92        }
93    }
94
95    /// The `--json` word for this row's verdict.
96    pub fn verdict_key(&self) -> &'static str {
97        self.verdict.key()
98    }
99}
100
101/// The whole report.
102pub struct TrustReport {
103    /// Structural guarantees. Identical on every machine.
104    pub guarantees: Vec<TrustRow>,
105    /// Live state, read from the registry and the OS.
106    pub machine: Vec<TrustRow>,
107}
108
109impl TrustReport {
110    /// Every setting on this machine that widens what dev-prune may do without asking.
111    ///
112    /// Not a score. A list, because "trust level: MEDIUM" tells nobody which switch to
113    /// look at, and the only useful version of this answer is the names.
114    pub fn widened(&self) -> Vec<&str> {
115        self.machine
116            .iter()
117            .filter(|r| r.verdict == Verdict::Widened)
118            .map(|r| r.subject)
119            .collect()
120    }
121}
122
123/// Run the `trust` command.
124pub fn run(json_output: bool) -> Result<()> {
125    let registry = Registry::load()?;
126    let report = build(&registry);
127
128    if json_output {
129        return json::emit(&json::trust_document(&report));
130    }
131
132    print_report(&report);
133    Ok(())
134}
135
136/// Assemble the report from the code's own guarantees and this machine's actual state.
137///
138/// `pub(crate)` because the first-run configurator opens on this same report: the
139/// declaration a new user reads and what `devp trust` prints later must be the same
140/// text, or one of them is a marketing claim.
141pub(crate) fn build(registry: &Registry) -> TrustReport {
142    TrustReport {
143        guarantees: guarantees(),
144        machine: machine_state(registry),
145    }
146}
147
148/// The seven safety invariants plus the two promises that are not invariants but are
149/// asked about just as often: no telemetry, and build outputs are never touched.
150///
151/// Every string here restates something enforced in `src/engine.rs` or `src/adapters/`.
152/// [`docs/SAFETY_INVARIANTS.md`](../../docs/SAFETY_INVARIANTS.md) is the long form.
153fn guarantees() -> Vec<TrustRow> {
154    use Verdict::Guaranteed as G;
155    vec![
156        TrustRow::new(
157            "filesystem_scope",
158            "Filesystem scope",
159            "Registered Git repositories only",
160            G,
161        ),
162        TrustRow::new(
163            "lockfile_verification",
164            "Lockfile verification",
165            "Required before every delete",
166            G,
167        ),
168        TrustRow::new("symlinks", "Symlinks and junctions", "Refused", G),
169        TrustRow::new(
170            "nested_repositories",
171            "Nested repositories",
172            "Refused — no lockfile rebuilds someone else's history",
173            G,
174        ),
175        TrustRow::new(
176            "build_outputs",
177            "Build outputs",
178            "Never deleted — no dist/, no .next/, no .gitignore rules",
179            G,
180        ),
181        TrustRow::new(
182            "deletion_bypass",
183            "Deletion bypass",
184            "None — no flag disables a safety check",
185            G,
186        ),
187        TrustRow::new(
188            "state_writes",
189            "State writes",
190            "Atomic — temp file, then rename",
191            G,
192        ),
193        TrustRow::new("telemetry", "Telemetry", "None — there is no endpoint", G),
194        TrustRow::new(
195            "restore",
196            "Restore",
197            "`devp restore --last-run` rebuilds the last pass",
198            G,
199        ),
200    ]
201}
202
203/// Everything read back from this machine rather than asserted.
204fn machine_state(registry: &Registry) -> Vec<TrustRow> {
205    let s = &registry.settings;
206    let mut rows = vec![
207        TrustRow::new(
208            "network",
209            "Network requests",
210            if s.update_check {
211                format!(
212                    "Release check against GitHub, every {} days",
213                    s.update_check_interval_days
214                )
215            } else {
216                "None — the release check is off".to_string()
217            },
218            Verdict::Safe,
219        ),
220        TrustRow::new(
221            "auto_update",
222            "Auto-update",
223            if s.auto_update {
224                "On — dev-prune replaces its own binary"
225            } else {
226                "Off — updates only when you run `devp update`"
227            },
228            if s.auto_update {
229                Verdict::Widened
230            } else {
231                Verdict::Safe
232            },
233        ),
234        TrustRow::new(
235            "confirmation",
236            "Confirmation before deleting",
237            if s.require_confirmation {
238                "Required, except where you pass `--yes`"
239            } else {
240                "Off — `require_confirmation` is false"
241            },
242            if s.require_confirmation {
243                Verdict::Safe
244            } else {
245                Verdict::Widened
246            },
247        ),
248        TrustRow::new(
249            "lockfile_rewrite",
250            "Lockfile rewriting",
251            if s.allow_manifest_rewrite {
252                "Allowed — a stale lockfile is regenerated instead of refused"
253            } else {
254                "Refused — verification is read-only"
255            },
256            if s.allow_manifest_rewrite {
257                Verdict::Widened
258            } else {
259                Verdict::Safe
260            },
261        ),
262        TrustRow::new(
263            "scheduler",
264            "Background scheduler",
265            scheduler_state(),
266            Verdict::Neutral,
267        ),
268        TrustRow::new("git_hooks", "Git hooks", hook_state(), Verdict::Neutral),
269    ];
270
271    // Opt-in adapters delete compiled output, which every other adapter refuses to do.
272    // Someone reading this report to find out what is deletable on their machine needs
273    // to see that they turned that on.
274    let opt_in = opt_in_adapters(registry);
275    rows.push(TrustRow::new(
276        "opt_in_adapters",
277        "Opt-in adapters",
278        if opt_in.is_empty() {
279            "None — only dependency directories are deletable".to_string()
280        } else {
281            format!("{} — build trees are deletable too", opt_in.join(", "))
282        },
283        if opt_in.is_empty() {
284            Verdict::Safe
285        } else {
286            Verdict::Widened
287        },
288    ));
289
290    rows.push(TrustRow::new(
291        "repositories",
292        "Registered repositories",
293        format!(
294            "{} — nothing outside them is ever read or written",
295            registry.repositories.len()
296        ),
297        Verdict::Neutral,
298    ));
299    rows.push(TrustRow::new(
300        "idle_window",
301        "Idle window",
302        format!(
303            "{} days of no commits and no file changes ({} for build trees)",
304            s.idle_days,
305            s.build_idle_days.max(s.idle_days)
306        ),
307        Verdict::Neutral,
308    ));
309    // The managed path, not `current_exe()`: on Windows the scheduler runs a patched
310    // twin and `devp update` replaces the managed copy, so the path that matters to
311    // someone asking what runs on this machine is the one dev-prune owns.
312    rows.push(TrustRow::new(
313        "binary",
314        "Managed binary",
315        output::clean_path(daemon::get_exe_path()),
316        Verdict::Neutral,
317    ));
318
319    rows
320}
321
322/// Which opt-in adapters are switched on, in the order the report should name them.
323fn opt_in_adapters(registry: &Registry) -> Vec<&'static str> {
324    let s = &registry.settings;
325    [
326        ("gradle", s.enable_gradle),
327        ("maven", s.enable_maven),
328        ("swift", s.enable_swift),
329    ]
330    .into_iter()
331    .filter_map(|(name, on)| on.then_some(name))
332    .collect()
333}
334
335/// Whether anything prunes on its own on this machine.
336fn scheduler_state() -> String {
337    match daemon::daemon_status() {
338        Ok(daemon::DaemonStatus::Installed) => "Installed — prunes on its own".to_string(),
339        Ok(daemon::DaemonStatus::NotInstalled) => {
340            "Not installed — nothing runs unless you run it".to_string()
341        }
342        Ok(daemon::DaemonStatus::Unknown(why)) => format!("Unknown ({why})"),
343        Err(e) => format!("Unknown ({e})"),
344    }
345}
346
347/// Whether Git hooks auto-register repositories on this machine.
348fn hook_state() -> String {
349    if !hook::git_available() {
350        return "Not installed — git is not on PATH".to_string();
351    }
352    match hook::state() {
353        Ok(HookState::Active) => "Installed — new repositories register themselves".to_string(),
354        Ok(HookState::Absent) => {
355            "Not installed — repositories register only when you say so".to_string()
356        }
357        Ok(HookState::Chained { previous, .. }) => {
358            format!("Installed, chained to `{previous}`")
359        }
360        Ok(HookState::Foreign(p)) => format!("Not ours — `core.hooksPath` belongs to `{p}`"),
361        Err(e) => format!("Unknown ({e})"),
362    }
363}
364
365fn print_report(report: &TrustReport) {
366    output::print_header(&format!("What dev-prune {} may do", constants::VERSION));
367
368    println!();
369    println!("  Guaranteed by the code, on every machine");
370    println!();
371    for row in &report.guarantees {
372        print_row(row);
373    }
374
375    println!();
376    println!("  On this machine");
377    println!();
378    for row in &report.machine {
379        print_row(row);
380    }
381
382    println!();
383    let widened = report.widened();
384    if widened.is_empty() {
385        output::print_success(
386            "Nothing on this machine widens what dev-prune may do without asking.",
387        );
388    } else {
389        output::print_info(&format!(
390            "{} {} what dev-prune may do without asking: {}. Each was switched on \
391             deliberately; `devp config show` has them.",
392            widened.len(),
393            if widened.len() == 1 {
394                "setting widens"
395            } else {
396                "settings widen"
397            },
398            widened.join(", ")
399        ));
400    }
401    output::print_info(
402        "The guarantees above are enforced in `src/engine.rs` and described in full at \
403         docs/SAFETY_INVARIANTS.md. None of them has a bypass flag.",
404    );
405}
406
407fn print_row(row: &TrustRow) {
408    println!(
409        "  {}  {:<30} {}",
410        row.verdict.mark(),
411        row.subject,
412        row.state
413    );
414}
415
416#[cfg(test)]
417mod tests {
418    use super::*;
419
420    #[test]
421    fn the_default_machine_widens_nothing() {
422        let registry = Registry::default();
423        let report = build(&registry);
424        assert!(
425            report.widened().is_empty(),
426            "a fresh install reports {:?} as widened",
427            report.widened()
428        );
429    }
430
431    #[test]
432    fn every_widening_setting_shows_up_by_name() {
433        let mut registry = Registry::default();
434        registry.settings.auto_update = true;
435        registry.settings.require_confirmation = false;
436        registry.settings.allow_manifest_rewrite = true;
437        registry.settings.enable_gradle = true;
438
439        let report = build(&registry);
440        let widened = report.widened();
441        assert_eq!(widened.len(), 4, "got {widened:?}");
442        // Named, not counted: a report that says "3 settings" and stops is a report
443        // nobody can act on.
444        assert!(widened.contains(&"Auto-update"));
445        assert!(widened.contains(&"Opt-in adapters"));
446    }
447
448    #[test]
449    fn opt_in_adapters_are_listed_in_a_stable_order() {
450        let mut registry = Registry::default();
451        registry.settings.enable_swift = true;
452        registry.settings.enable_gradle = true;
453        assert_eq!(opt_in_adapters(&registry), vec!["gradle", "swift"]);
454    }
455
456    #[test]
457    fn every_row_key_is_unique() {
458        // The keys are the `--json` contract; two rows sharing one silently drops a
459        // fact from the document.
460        let report = build(&Registry::default());
461        let mut keys: Vec<&str> = report
462            .guarantees
463            .iter()
464            .chain(report.machine.iter())
465            .map(|r| r.key)
466            .collect();
467        let total = keys.len();
468        keys.sort_unstable();
469        keys.dedup();
470        assert_eq!(keys.len(), total);
471    }
472
473    #[test]
474    fn guarantees_never_depend_on_settings() {
475        // If a "guarantee" could be turned off it would not be one, and the report would
476        // be claiming something the code does not enforce.
477        let mut registry = Registry::default();
478        registry.settings.allow_manifest_rewrite = true;
479        registry.settings.auto_update = true;
480        let with = build(&registry);
481        let without = build(&Registry::default());
482
483        let states = |r: &TrustReport| -> Vec<String> {
484            r.guarantees.iter().map(|g| g.state.clone()).collect()
485        };
486        assert_eq!(states(&with), states(&without));
487    }
488}