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
136pub(crate) fn build(registry: &Registry) -> TrustReport {
142 TrustReport {
143 guarantees: guarantees(),
144 machine: machine_state(registry),
145 }
146}
147
148fn 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
203fn machine_state(registry: &Registry) -> Vec<TrustRow> {
205 let s = ®istry.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 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 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
322fn opt_in_adapters(registry: &Registry) -> Vec<&'static str> {
324 let s = ®istry.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
335fn 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
347fn 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(®istry);
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(®istry);
440 let widened = report.widened();
441 assert_eq!(widened.len(), 4, "got {widened:?}");
442 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(®istry), vec!["gradle", "swift"]);
454 }
455
456 #[test]
457 fn every_row_key_is_unique() {
458 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 let mut registry = Registry::default();
478 registry.settings.allow_manifest_rewrite = true;
479 registry.settings.auto_update = true;
480 let with = build(®istry);
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}