1use 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#[derive(Clone, Copy, PartialEq, Eq)]
34pub enum Verdict {
35 Guaranteed,
37 Safe,
39 Widened,
42 Neutral,
44}
45
46impl Verdict {
47 fn mark(self) -> &'static str {
49 match self {
50 Verdict::Guaranteed | Verdict::Safe => "+",
51 Verdict::Widened => "!",
52 Verdict::Neutral => " ",
53 }
54 }
55
56 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
68pub struct TrustRow {
70 pub key: &'static str,
72 pub subject: &'static str,
74 pub state: String,
76 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 pub fn verdict_key(&self) -> &'static str {
97 self.verdict.key()
98 }
99}
100
101pub struct TrustReport {
103 pub guarantees: Vec<TrustRow>,
105 pub machine: Vec<TrustRow>,
107}
108
109impl TrustReport {
110 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
123pub fn run(json_output: bool) -> Result<()> {
125 let registry = Registry::load()?;
126 let report = build(®istry);
127
128 if json_output {
129 return json::emit(&json::trust_document(&report));
130 }
131
132 print_report(&report);
133 Ok(())
134}
135
136fn machine_answers() -> (String, String) {
143 let scheduler = std::thread::spawn(scheduler_state);
144 let hooks = hook_state();
145 let scheduler = scheduler
148 .join()
149 .unwrap_or_else(|_| "Unknown (the check did not finish)".to_string());
150 (scheduler, hooks)
151}
152
153fn with_progress<T>(work: impl FnOnce() -> T) -> T {
160 use std::io::{IsTerminal, Write};
161
162 let mut err = std::io::stderr();
163 let show = err.is_terminal();
164 if show {
165 let _ = write!(err, "{}", constants::READING_MACHINE);
166 let _ = err.flush();
167 }
168 let value = work();
169 if show {
170 let _ = write!(
173 err,
174 "\r{:width$}\r",
175 "",
176 width = constants::READING_MACHINE.chars().count()
177 );
178 let _ = err.flush();
179 }
180 value
181}
182
183pub(crate) fn build(registry: &Registry) -> TrustReport {
189 TrustReport {
190 guarantees: guarantees(),
191 machine: machine_state(registry),
192 }
193}
194
195fn guarantees() -> Vec<TrustRow> {
201 use Verdict::Guaranteed as G;
202 vec![
203 TrustRow::new(
204 "filesystem_scope",
205 "Filesystem scope",
206 "Registered Git repositories only",
207 G,
208 ),
209 TrustRow::new(
210 "lockfile_verification",
211 "Lockfile verification",
212 "Required before every delete",
213 G,
214 ),
215 TrustRow::new("symlinks", "Symlinks and junctions", "Refused", G),
216 TrustRow::new(
217 "nested_repositories",
218 "Nested repositories",
219 "Refused — no lockfile rebuilds someone else's history",
220 G,
221 ),
222 TrustRow::new(
223 "build_outputs",
224 "Build outputs",
225 "Never deleted — no dist/, no .next/, no .gitignore rules",
226 G,
227 ),
228 TrustRow::new(
229 "deletion_bypass",
230 "Deletion bypass",
231 "None — no flag disables a safety check",
232 G,
233 ),
234 TrustRow::new(
235 "state_writes",
236 "State writes",
237 "Atomic — temp file, then rename",
238 G,
239 ),
240 TrustRow::new("telemetry", "Telemetry", "None — there is no endpoint", G),
241 TrustRow::new(
242 "restore",
243 "Restore",
244 "`devp restore --last-run` rebuilds the last pass",
245 G,
246 ),
247 ]
248}
249
250fn machine_state(registry: &Registry) -> Vec<TrustRow> {
252 let s = ®istry.settings;
253 let (scheduler, hooks) = with_progress(machine_answers);
254 let mut rows = vec![
255 TrustRow::new(
256 "network",
257 "Network requests",
258 if s.update_check {
259 format!(
260 "Release check against GitHub, every {} days",
261 s.update_check_interval_days
262 )
263 } else {
264 "None — the release check is off".to_string()
265 },
266 Verdict::Safe,
267 ),
268 TrustRow::new(
269 "auto_update",
270 "Auto-update",
271 if s.auto_update {
272 "On — dev-prune replaces its own binary"
273 } else {
274 "Off — updates only when you run `devp update`"
275 },
276 if s.auto_update {
277 Verdict::Widened
278 } else {
279 Verdict::Safe
280 },
281 ),
282 TrustRow::new(
283 "confirmation",
284 "Confirmation before deleting",
285 if s.require_confirmation {
286 "Required, except where you pass `--yes`"
287 } else {
288 "Off — `require_confirmation` is false"
289 },
290 if s.require_confirmation {
291 Verdict::Safe
292 } else {
293 Verdict::Widened
294 },
295 ),
296 TrustRow::new(
297 "lockfile_rewrite",
298 "Lockfile rewriting",
299 if s.allow_manifest_rewrite {
300 "Allowed — a stale lockfile is regenerated instead of refused"
301 } else {
302 "Refused — verification is read-only"
303 },
304 if s.allow_manifest_rewrite {
305 Verdict::Widened
306 } else {
307 Verdict::Safe
308 },
309 ),
310 TrustRow::new(
311 "scheduler",
312 "Background scheduler",
313 scheduler,
314 Verdict::Neutral,
315 ),
316 TrustRow::new("git_hooks", "Git hooks", hooks, Verdict::Neutral),
317 ];
318
319 let opt_in = opt_in_adapters(registry);
323 rows.push(TrustRow::new(
324 "opt_in_adapters",
325 "Opt-in adapters",
326 if opt_in.is_empty() {
327 "None — only dependency directories are deletable".to_string()
328 } else {
329 format!("{} — build trees are deletable too", opt_in.join(", "))
330 },
331 if opt_in.is_empty() {
332 Verdict::Safe
333 } else {
334 Verdict::Widened
335 },
336 ));
337
338 rows.push(TrustRow::new(
339 "repositories",
340 "Registered repositories",
341 format!(
342 "{} — nothing outside them is ever read or written",
343 registry.repositories.len()
344 ),
345 Verdict::Neutral,
346 ));
347 rows.push(TrustRow::new(
348 "idle_window",
349 "Idle window",
350 format!(
351 "{} days of no commits and no file changes ({} for build trees, before any per-adapter window)",
352 s.idle_days,
353 s.build_idle_days.max(s.idle_days)
354 ),
355 Verdict::Neutral,
356 ));
357 rows.push(TrustRow::new(
361 "binary",
362 "Managed binary",
363 output::clean_path(daemon::get_exe_path()),
364 Verdict::Neutral,
365 ));
366
367 rows
368}
369
370fn opt_in_adapters(registry: &Registry) -> Vec<&'static str> {
372 let s = ®istry.settings;
373 [
374 ("cargo", s.enable_cargo),
375 ("gradle", s.enable_gradle),
376 ("maven", s.enable_maven),
377 ("swift", s.enable_swift),
378 ("dart", s.enable_dart),
379 ]
380 .into_iter()
381 .filter_map(|(name, on)| on.then_some(name))
382 .collect()
383}
384
385fn scheduler_state() -> String {
387 match daemon::daemon_status() {
388 Ok(daemon::DaemonStatus::Installed) => "Installed — prunes on its own".to_string(),
389 Ok(daemon::DaemonStatus::NotInstalled) => {
390 "Not installed — nothing runs unless you run it".to_string()
391 }
392 Ok(daemon::DaemonStatus::Unknown(why)) => format!("Unknown ({why})"),
393 Err(e) => format!("Unknown ({e})"),
394 }
395}
396
397fn hook_state() -> String {
399 if !hook::git_available() {
400 return "Not installed — git is not on PATH".to_string();
401 }
402 match hook::state() {
403 Ok(HookState::Active) => "Installed — new repositories register themselves".to_string(),
404 Ok(HookState::Absent) => {
405 "Not installed — repositories register only when you say so".to_string()
406 }
407 Ok(HookState::Chained { previous, .. }) => {
408 format!("Installed, chained to `{previous}`")
409 }
410 Ok(HookState::Foreign(p)) => format!("Not ours — `core.hooksPath` belongs to `{p}`"),
411 Err(e) => format!("Unknown ({e})"),
412 }
413}
414
415fn print_report(report: &TrustReport) {
416 output::print_header(&format!("What dev-prune {} may do", constants::VERSION));
417
418 println!();
419 println!(" Guaranteed by the code, on every machine");
420 println!();
421 for row in &report.guarantees {
422 print_row(row);
423 }
424
425 println!();
426 println!(" On this machine");
427 println!();
428 for row in &report.machine {
429 print_row(row);
430 }
431
432 println!();
433 let widened = report.widened();
434 if widened.is_empty() {
435 output::print_success(
436 "Nothing on this machine widens what dev-prune may do without asking.",
437 );
438 } else {
439 output::print_info(&format!(
440 "{} {} what dev-prune may do without asking: {}. Each was switched on \
441 deliberately; `devp config show` has them.",
442 widened.len(),
443 if widened.len() == 1 {
444 "setting widens"
445 } else {
446 "settings widen"
447 },
448 widened.join(", ")
449 ));
450 }
451 output::print_info(
452 "The guarantees above are enforced in `src/engine.rs` and described in full at \
453 docs/SAFETY_INVARIANTS.md. None of them has a bypass flag.",
454 );
455}
456
457fn print_row(row: &TrustRow) {
458 println!(
459 " {} {:<30} {}",
460 row.verdict.mark(),
461 row.subject,
462 row.state
463 );
464}
465
466#[cfg(test)]
467mod tests {
468 use super::*;
469
470 #[test]
471 fn the_default_machine_widens_nothing() {
472 let registry = Registry::default();
473 let report = build(®istry);
474 assert!(
475 report.widened().is_empty(),
476 "a fresh install reports {:?} as widened",
477 report.widened()
478 );
479 }
480
481 #[test]
482 fn every_widening_setting_shows_up_by_name() {
483 let mut registry = Registry::default();
484 registry.settings.auto_update = true;
485 registry.settings.require_confirmation = false;
486 registry.settings.allow_manifest_rewrite = true;
487 registry.settings.enable_gradle = true;
488
489 let report = build(®istry);
490 let widened = report.widened();
491 assert_eq!(widened.len(), 4, "got {widened:?}");
492 assert!(widened.contains(&"Auto-update"));
495 assert!(widened.contains(&"Opt-in adapters"));
496 }
497
498 #[test]
499 fn opt_in_adapters_are_listed_in_a_stable_order() {
500 let mut registry = Registry::default();
501 registry.settings.enable_swift = true;
502 registry.settings.enable_gradle = true;
503 assert_eq!(opt_in_adapters(®istry), vec!["gradle", "swift"]);
504 }
505
506 #[test]
507 fn every_row_key_is_unique() {
508 let report = build(&Registry::default());
511 let mut keys: Vec<&str> = report
512 .guarantees
513 .iter()
514 .chain(report.machine.iter())
515 .map(|r| r.key)
516 .collect();
517 let total = keys.len();
518 keys.sort_unstable();
519 keys.dedup();
520 assert_eq!(keys.len(), total);
521 }
522
523 #[test]
524 fn guarantees_never_depend_on_settings() {
525 let mut registry = Registry::default();
528 registry.settings.allow_manifest_rewrite = true;
529 registry.settings.auto_update = true;
530 let with = build(®istry);
531 let without = build(&Registry::default());
532
533 let states = |r: &TrustReport| -> Vec<String> {
534 r.guarantees.iter().map(|g| g.state.clone()).collect()
535 };
536 assert_eq!(states(&with), states(&without));
537 }
538}