Skip to main content

running_process/cleanup/
verify_artifacts.rs

1//! Exhaustive daemon-artifact reconciliation for `cleanup verify` (#391,
2//! part of #354).
3//!
4//! Enumerates every artifact class the daemon can leave behind — IPC
5//! socket, pid file, `.servicedef` files, the SQLite registry database
6//! (plus WAL/SHM sidecars), log files, the ENOSPC emergency reserve, and
7//! shadow-dir contents — and reports each location as clean, active,
8//! present, stale, or orphaned. READ-ONLY by contract: nothing is created,
9//! deleted, or rewritten. The documented operator checklist lives in
10//! `docs/v1-troubleshooting.md` ("Cleanup Verification").
11//!
12//! Every check is a pure function over injected paths/probes so tests can
13//! exercise each class with temp dirs on all platforms; only the
14//! `ArtifactPaths::from_environment` constructor and the default probes
15//! in `run` touch the real environment.
16
17use std::path::{Path, PathBuf};
18
19use crate::broker::backend_lifecycle::verify_pid::process_is_alive;
20use crate::broker::server::service_def_loader::SERVICE_DEF_EXTENSION;
21use crate::client::paths;
22
23/// Leaf name of the ENOSPC emergency reserve file (#390). Mirrors
24/// `daemon::emergency_reserve::EMERGENCY_RESERVE_FILE_NAME`, duplicated
25/// here because this module is `client`-only while the canonical constant
26/// is gated behind the `daemon` feature.
27pub const EMERGENCY_RESERVE_FILE_NAME: &str = "emergency-reserve.bin";
28/// Expected size of a fully-armed emergency reserve. Mirrors
29/// `daemon::emergency_reserve::EMERGENCY_RESERVE_BYTES`.
30pub const EMERGENCY_RESERVE_BYTES: u64 = 32 * 1024 * 1024;
31
32/// Reconciliation outcome for one artifact location.
33#[derive(Clone, Copy, Debug, PartialEq, Eq)]
34pub enum ArtifactStatus {
35    /// Expected location is empty — nothing left behind.
36    Clean,
37    /// Artifact exists and belongs to a live daemon.
38    Active,
39    /// Artifact exists and is expected to persist across daemon runs
40    /// (databases, service definitions, armed reserve, shadow copies).
41    Present,
42    /// Artifact exists but its owner is gone (dead pid, refused socket,
43    /// WAL left by an unclean shutdown, truncated reserve).
44    Stale,
45    /// Unexpected file in a managed location.
46    Orphaned,
47    /// The location could not be inspected.
48    Error,
49}
50
51impl ArtifactStatus {
52    /// Stable uppercase label used in both text and JSON output.
53    pub fn as_str(self) -> &'static str {
54        match self {
55            ArtifactStatus::Clean => "CLEAN",
56            ArtifactStatus::Active => "ACTIVE",
57            ArtifactStatus::Present => "PRESENT",
58            ArtifactStatus::Stale => "STALE",
59            ArtifactStatus::Orphaned => "ORPHANED",
60            ArtifactStatus::Error => "ERROR",
61        }
62    }
63
64    /// True when this status flags residue worth operator attention.
65    pub fn is_finding(self) -> bool {
66        matches!(
67            self,
68            ArtifactStatus::Stale | ArtifactStatus::Orphaned | ArtifactStatus::Error
69        )
70    }
71}
72
73/// One reconciled artifact location.
74#[derive(Clone, Debug)]
75pub struct ArtifactCheck {
76    /// Stable artifact class identifier, e.g. `socket`, `pid-file`.
77    pub class: &'static str,
78    /// Location inspected (path or pipe name).
79    pub location: String,
80    /// Reconciliation outcome.
81    pub status: ArtifactStatus,
82    /// Human-readable one-line detail.
83    pub detail: String,
84}
85
86impl ArtifactCheck {
87    fn new(
88        class: &'static str,
89        location: impl Into<String>,
90        status: ArtifactStatus,
91        detail: impl Into<String>,
92    ) -> Self {
93        Self {
94            class,
95            location: location.into(),
96            status,
97            detail: detail.into(),
98        }
99    }
100}
101
102/// Aggregated artifact reconciliation report.
103#[derive(Clone, Debug, Default)]
104pub struct ArtifactReport {
105    /// Every artifact location inspected, in execution order.
106    pub checks: Vec<ArtifactCheck>,
107}
108
109impl ArtifactReport {
110    /// Number of STALE/ORPHANED/ERROR entries.
111    pub fn finding_count(&self) -> usize {
112        self.checks
113            .iter()
114            .filter(|check| check.status.is_finding())
115            .count()
116    }
117
118    /// Process exit code contract (mirrors `broker doctor`): 0 unless a
119    /// location could not be inspected. Stale/orphaned residue is reported
120    /// but does not fail the command — verification is advisory.
121    pub fn exit_code(&self) -> i32 {
122        if self
123            .checks
124            .iter()
125            .any(|check| check.status == ArtifactStatus::Error)
126        {
127            1
128        } else {
129            0
130        }
131    }
132
133    /// Stable machine-readable JSON value (additive-only shape).
134    pub fn to_json_value(&self) -> serde_json::Value {
135        let checks: Vec<serde_json::Value> = self
136            .checks
137            .iter()
138            .map(|check| {
139                serde_json::json!({
140                    "class": check.class,
141                    "location": check.location,
142                    "status": check.status.as_str(),
143                    "detail": check.detail,
144                })
145            })
146            .collect();
147        serde_json::json!({
148            "schema_version": 1,
149            "exit_code": self.exit_code(),
150            "findings": self.finding_count(),
151            "checks": checks,
152        })
153    }
154
155    /// Human-readable table plus a one-line summary.
156    pub fn render_text(&self) -> String {
157        let class_width = self
158            .checks
159            .iter()
160            .map(|check| check.class.len())
161            .max()
162            .unwrap_or(0);
163        let mut out = String::new();
164        for check in &self.checks {
165            out.push_str(&format!(
166                "{:<8}  {:<class_width$}  {}  {}\n",
167                check.status.as_str(),
168                check.class,
169                check.location,
170                check.detail,
171            ));
172        }
173        out.push_str(&format!(
174            "cleanup verify: {} location(s) — {} finding(s)\n",
175            self.checks.len(),
176            self.finding_count()
177        ));
178        out
179    }
180}
181
182/// Where the daemon socket lives on this platform.
183#[derive(Clone, Debug)]
184pub enum SocketLocation {
185    /// Unix-domain socket file.
186    File(PathBuf),
187    /// Windows named pipe (no filesystem residue).
188    NamedPipe(String),
189}
190
191/// Every expected daemon artifact location. Fully injectable for tests;
192/// [`Self::from_environment`] derives the real platform locations without
193/// creating any directory.
194#[derive(Clone, Debug)]
195pub struct ArtifactPaths {
196    /// Daemon IPC endpoint.
197    pub socket: SocketLocation,
198    /// Daemon pid/identity file.
199    pub pid_file: PathBuf,
200    /// SQLite registry database.
201    pub db: PathBuf,
202    /// Daemon data directory (scanned for log files).
203    pub data_dir: PathBuf,
204    /// ENOSPC emergency reserve file (#390).
205    pub emergency_reserve: PathBuf,
206    /// Expected byte size of a fully-armed reserve.
207    pub emergency_reserve_bytes: u64,
208    /// Service-definition directory (`*.servicedef`, #364).
209    pub service_definition_dir: PathBuf,
210    /// Shadow-copy directory for relocated daemon binaries.
211    pub shadow_dir: PathBuf,
212}
213
214impl ArtifactPaths {
215    /// Derive every expected location from the environment, read-only.
216    pub fn from_environment(scope_hash: Option<&str>) -> Self {
217        let endpoint = paths::socket_path_view(scope_hash);
218        let socket = if cfg!(windows) {
219            SocketLocation::NamedPipe(endpoint)
220        } else {
221            SocketLocation::File(PathBuf::from(endpoint))
222        };
223        let data_dir = paths::data_dir();
224        Self {
225            socket,
226            pid_file: paths::pid_file_path_view(scope_hash),
227            db: paths::db_path_view(scope_hash),
228            emergency_reserve: data_dir.join(EMERGENCY_RESERVE_FILE_NAME),
229            emergency_reserve_bytes: EMERGENCY_RESERVE_BYTES,
230            data_dir,
231            service_definition_dir:
232                crate::broker::server::service_def_loader::service_definition_dir(),
233            shadow_dir: paths::shadow_dir_view(),
234        }
235    }
236}
237
238/// Reconcile every artifact class against the live environment.
239pub fn run(paths: &ArtifactPaths) -> ArtifactReport {
240    let connect =
241        |endpoint: &str| crate::broker::client::connect_local_socket(endpoint).map(|_stream| ());
242    run_with_probes(paths, &process_is_alive, &connect)
243}
244
245/// `run` with injected liveness/connect probes (test seam).
246pub fn run_with_probes(
247    paths: &ArtifactPaths,
248    pid_is_alive: &dyn Fn(u32) -> bool,
249    connect: &dyn Fn(&str) -> std::io::Result<()>,
250) -> ArtifactReport {
251    let pid_check = check_pid_file(&paths.pid_file, pid_is_alive);
252    let daemon_alive = pid_check.status == ArtifactStatus::Active;
253    let mut checks = vec![check_socket(&paths.socket, connect), pid_check];
254    checks.extend(check_service_definitions(&paths.service_definition_dir));
255    checks.extend(check_database(&paths.db, daemon_alive));
256    checks.push(check_logs(&paths.data_dir));
257    checks.push(check_emergency_reserve(
258        &paths.emergency_reserve,
259        paths.emergency_reserve_bytes,
260    ));
261    checks.push(check_shadow_dir(&paths.shadow_dir, daemon_alive));
262    ArtifactReport { checks }
263}
264
265/// Reconcile the daemon IPC endpoint.
266pub fn check_socket(
267    socket: &SocketLocation,
268    connect: &dyn Fn(&str) -> std::io::Result<()>,
269) -> ArtifactCheck {
270    const CLASS: &str = "socket";
271    match socket {
272        SocketLocation::NamedPipe(name) => match connect(name) {
273            Ok(()) => ArtifactCheck::new(
274                CLASS,
275                name.clone(),
276                ArtifactStatus::Active,
277                "named pipe accepts connections",
278            ),
279            Err(_) => ArtifactCheck::new(
280                CLASS,
281                name.clone(),
282                ArtifactStatus::Clean,
283                "no listener (named pipes leave no filesystem residue)",
284            ),
285        },
286        SocketLocation::File(path) => {
287            if !path.exists() {
288                return ArtifactCheck::new(
289                    CLASS,
290                    path.display().to_string(),
291                    ArtifactStatus::Clean,
292                    "socket file absent",
293                );
294            }
295            match connect(&path.to_string_lossy()) {
296                Ok(()) => ArtifactCheck::new(
297                    CLASS,
298                    path.display().to_string(),
299                    ArtifactStatus::Active,
300                    "socket file exists and accepts connections",
301                ),
302                Err(err) => ArtifactCheck::new(
303                    CLASS,
304                    path.display().to_string(),
305                    ArtifactStatus::Stale,
306                    format!("socket file exists but nothing is listening ({err})"),
307                ),
308            }
309        }
310    }
311}
312
313/// Reconcile the daemon pid file against process liveness.
314pub fn check_pid_file(path: &Path, pid_is_alive: &dyn Fn(u32) -> bool) -> ArtifactCheck {
315    const CLASS: &str = "pid-file";
316    let location = path.display().to_string();
317    let contents = match std::fs::read_to_string(path) {
318        Ok(contents) => contents,
319        Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
320            return ArtifactCheck::new(CLASS, location, ArtifactStatus::Clean, "pid file absent");
321        }
322        Err(err) => {
323            return ArtifactCheck::new(
324                CLASS,
325                location,
326                ArtifactStatus::Error,
327                format!("cannot read pid file: {err}"),
328            );
329        }
330    };
331    match contents.trim().parse::<u32>() {
332        Ok(pid) if pid_is_alive(pid) => ArtifactCheck::new(
333            CLASS,
334            location,
335            ArtifactStatus::Active,
336            format!("daemon pid {pid} is alive"),
337        ),
338        Ok(pid) => ArtifactCheck::new(
339            CLASS,
340            location,
341            ArtifactStatus::Stale,
342            format!("pid {pid} is not alive"),
343        ),
344        Err(_) => ArtifactCheck::new(
345            CLASS,
346            location,
347            ArtifactStatus::Stale,
348            format!("unparsable pid file contents {:?}", contents.trim()),
349        ),
350    }
351}
352
353/// Reconcile the service-definition directory: `.servicedef` files are
354/// expected persistent config; anything else in the directory is orphaned.
355pub fn check_service_definitions(dir: &Path) -> Vec<ArtifactCheck> {
356    const CLASS: &str = "servicedef";
357    let location = dir.display().to_string();
358    if !dir.exists() {
359        return vec![ArtifactCheck::new(
360            CLASS,
361            location,
362            ArtifactStatus::Clean,
363            "service-definition directory absent (no services installed)",
364        )];
365    }
366    let entries = match std::fs::read_dir(dir) {
367        Ok(entries) => entries,
368        Err(err) => {
369            return vec![ArtifactCheck::new(
370                CLASS,
371                location,
372                ArtifactStatus::Error,
373                format!("cannot enumerate directory: {err}"),
374            )];
375        }
376    };
377    let mut paths: Vec<PathBuf> = entries
378        .filter_map(|entry| entry.ok().map(|entry| entry.path()))
379        .collect();
380    paths.sort();
381    let mut definitions = 0usize;
382    let mut checks = Vec::new();
383    for path in &paths {
384        let is_definition = path
385            .extension()
386            .map(|ext| ext == SERVICE_DEF_EXTENSION)
387            .unwrap_or(false);
388        if is_definition {
389            definitions += 1;
390        } else {
391            checks.push(ArtifactCheck::new(
392                CLASS,
393                path.display().to_string(),
394                ArtifactStatus::Orphaned,
395                format!("unexpected non-.{SERVICE_DEF_EXTENSION} entry in service-definition dir"),
396            ));
397        }
398    }
399    checks.insert(
400        0,
401        ArtifactCheck::new(
402            CLASS,
403            location,
404            ArtifactStatus::Present,
405            format!("{definitions} .{SERVICE_DEF_EXTENSION} file(s) (persistent config, expected)"),
406        ),
407    );
408    checks
409}
410
411/// Reconcile the SQLite registry database and its WAL/SHM sidecars.
412pub fn check_database(db: &Path, daemon_alive: bool) -> Vec<ArtifactCheck> {
413    const CLASS: &str = "database";
414    let mut checks = Vec::new();
415    let location = db.display().to_string();
416    if db.exists() {
417        let size = std::fs::metadata(db).map(|meta| meta.len()).unwrap_or(0);
418        checks.push(ArtifactCheck::new(
419            CLASS,
420            location,
421            ArtifactStatus::Present,
422            format!("registry database exists ({size} bytes; persists across daemon runs)"),
423        ));
424    } else {
425        checks.push(ArtifactCheck::new(
426            CLASS,
427            location,
428            ArtifactStatus::Clean,
429            "registry database absent (daemon never ran in this scope)",
430        ));
431    }
432    for suffix in ["-wal", "-shm"] {
433        let mut name = db.as_os_str().to_os_string();
434        name.push(suffix);
435        let sidecar = PathBuf::from(name);
436        if !sidecar.exists() {
437            continue;
438        }
439        let location = sidecar.display().to_string();
440        if daemon_alive {
441            checks.push(ArtifactCheck::new(
442                CLASS,
443                location,
444                ArtifactStatus::Active,
445                format!("sqlite {suffix} sidecar held by the live daemon"),
446            ));
447        } else {
448            checks.push(ArtifactCheck::new(
449                CLASS,
450                location,
451                ArtifactStatus::Stale,
452                format!(
453                    "sqlite {suffix} sidecar left behind with no live daemon (unclean shutdown)"
454                ),
455            ));
456        }
457    }
458    checks
459}
460
461/// Reconcile log files (`*.log`) in the daemon data directory. None are
462/// expected by default; any found are reported, never deleted.
463pub fn check_logs(data_dir: &Path) -> ArtifactCheck {
464    const CLASS: &str = "logs";
465    let location = data_dir.display().to_string();
466    if !data_dir.exists() {
467        return ArtifactCheck::new(
468            CLASS,
469            location,
470            ArtifactStatus::Clean,
471            "data directory absent (no log files)",
472        );
473    }
474    let entries = match std::fs::read_dir(data_dir) {
475        Ok(entries) => entries,
476        Err(err) => {
477            return ArtifactCheck::new(
478                CLASS,
479                location,
480                ArtifactStatus::Error,
481                format!("cannot enumerate data directory: {err}"),
482            );
483        }
484    };
485    let mut count = 0usize;
486    let mut bytes = 0u64;
487    for path in entries.filter_map(|entry| entry.ok().map(|entry| entry.path())) {
488        if path.extension().map(|ext| ext == "log").unwrap_or(false) {
489            count += 1;
490            bytes += std::fs::metadata(&path).map(|meta| meta.len()).unwrap_or(0);
491        }
492    }
493    if count == 0 {
494        ArtifactCheck::new(CLASS, location, ArtifactStatus::Clean, "no *.log files")
495    } else {
496        ArtifactCheck::new(
497            CLASS,
498            location,
499            ArtifactStatus::Present,
500            format!("{count} *.log file(s), {bytes} bytes total (reported, not deleted)"),
501        )
502    }
503}
504
505/// Reconcile the 32 MiB ENOSPC emergency reserve (#390).
506pub fn check_emergency_reserve(path: &Path, expected_bytes: u64) -> ArtifactCheck {
507    const CLASS: &str = "emergency-reserve";
508    let location = path.display().to_string();
509    match std::fs::metadata(path) {
510        Ok(meta) if meta.len() == expected_bytes => ArtifactCheck::new(
511            CLASS,
512            location,
513            ArtifactStatus::Present,
514            format!("armed at {expected_bytes} bytes (recreated at every daemon startup)"),
515        ),
516        Ok(meta) => ArtifactCheck::new(
517            CLASS,
518            location,
519            ArtifactStatus::Stale,
520            format!(
521                "unexpected size {} bytes (expected {expected_bytes}); partial pre-allocation \
522                 from a crashed startup",
523                meta.len()
524            ),
525        ),
526        Err(err) if err.kind() == std::io::ErrorKind::NotFound => ArtifactCheck::new(
527            CLASS,
528            location,
529            ArtifactStatus::Clean,
530            "absent (released after ENOSPC or daemon never ran; re-armed at next startup)",
531        ),
532        Err(err) => ArtifactCheck::new(
533            CLASS,
534            location,
535            ArtifactStatus::Error,
536            format!("cannot inspect reserve file: {err}"),
537        ),
538    }
539}
540
541/// Reconcile shadow-dir contents (relocated daemon binaries).
542pub fn check_shadow_dir(dir: &Path, daemon_alive: bool) -> ArtifactCheck {
543    const CLASS: &str = "shadow";
544    let location = dir.display().to_string();
545    if !dir.exists() {
546        return ArtifactCheck::new(
547            CLASS,
548            location,
549            ArtifactStatus::Clean,
550            "shadow directory absent",
551        );
552    }
553    let entries = match std::fs::read_dir(dir) {
554        Ok(entries) => entries,
555        Err(err) => {
556            return ArtifactCheck::new(
557                CLASS,
558                location,
559                ArtifactStatus::Error,
560                format!("cannot enumerate shadow directory: {err}"),
561            );
562        }
563    };
564    let count = entries.filter_map(|entry| entry.ok()).count();
565    if count == 0 {
566        ArtifactCheck::new(CLASS, location, ArtifactStatus::Clean, "empty")
567    } else if daemon_alive {
568        ArtifactCheck::new(
569            CLASS,
570            location,
571            ArtifactStatus::Active,
572            format!(
573                "{count} entr{} (may include the running daemon's shadow copy)",
574                if count == 1 { "y" } else { "ies" }
575            ),
576        )
577    } else {
578        ArtifactCheck::new(
579            CLASS,
580            location,
581            ArtifactStatus::Present,
582            format!(
583                "{count} entr{} with no live daemon (shadow copies persist by design; \
584                 prune manually if disk space matters)",
585                if count == 1 { "y" } else { "ies" }
586            ),
587        )
588    }
589}
590
591#[cfg(test)]
592mod tests {
593    use super::*;
594
595    fn temp_dir(label: &str) -> PathBuf {
596        let dir = std::env::temp_dir().join(format!(
597            "rp-verify-artifacts-{label}-{}-{:?}",
598            std::process::id(),
599            std::thread::current().id()
600        ));
601        let _ = std::fs::remove_dir_all(&dir);
602        std::fs::create_dir_all(&dir).unwrap();
603        dir
604    }
605
606    #[test]
607    fn pid_file_absent_is_clean() {
608        let dir = temp_dir("pid-clean");
609        let check = check_pid_file(&dir.join("daemon.pid"), &|_| true);
610        assert_eq!(check.status, ArtifactStatus::Clean);
611        let _ = std::fs::remove_dir_all(&dir);
612    }
613
614    #[test]
615    fn pid_file_live_pid_is_active_dead_pid_is_stale() {
616        let dir = temp_dir("pid-live");
617        let path = dir.join("daemon.pid");
618        std::fs::write(&path, "4242\n").unwrap();
619        assert_eq!(
620            check_pid_file(&path, &|pid| pid == 4242).status,
621            ArtifactStatus::Active
622        );
623        assert_eq!(
624            check_pid_file(&path, &|_| false).status,
625            ArtifactStatus::Stale
626        );
627        let _ = std::fs::remove_dir_all(&dir);
628    }
629
630    #[test]
631    fn pid_file_garbage_is_stale() {
632        let dir = temp_dir("pid-garbage");
633        let path = dir.join("daemon.pid");
634        std::fs::write(&path, "not-a-pid").unwrap();
635        let check = check_pid_file(&path, &|_| true);
636        assert_eq!(check.status, ArtifactStatus::Stale);
637        assert!(check.detail.contains("unparsable"));
638        let _ = std::fs::remove_dir_all(&dir);
639    }
640
641    #[test]
642    fn socket_file_states() {
643        let dir = temp_dir("socket");
644        let path = dir.join("daemon.sock");
645        let absent = SocketLocation::File(path.clone());
646        assert_eq!(
647            check_socket(&absent, &|_| Ok(())).status,
648            ArtifactStatus::Clean
649        );
650
651        std::fs::write(&path, b"").unwrap();
652        assert_eq!(
653            check_socket(&SocketLocation::File(path.clone()), &|_| Ok(())).status,
654            ArtifactStatus::Active
655        );
656        let refused = |_endpoint: &str| -> std::io::Result<()> {
657            Err(std::io::Error::from(std::io::ErrorKind::ConnectionRefused))
658        };
659        let check = check_socket(&SocketLocation::File(path), &refused);
660        assert_eq!(check.status, ArtifactStatus::Stale);
661        assert!(check.detail.contains("nothing is listening"));
662        let _ = std::fs::remove_dir_all(&dir);
663    }
664
665    #[test]
666    fn named_pipe_states() {
667        let pipe = SocketLocation::NamedPipe(r"\\.\pipe\rp-test".into());
668        assert_eq!(
669            check_socket(&pipe, &|_| Ok(())).status,
670            ArtifactStatus::Active
671        );
672        let gone = |_endpoint: &str| -> std::io::Result<()> {
673            Err(std::io::Error::from(std::io::ErrorKind::NotFound))
674        };
675        assert_eq!(check_socket(&pipe, &gone).status, ArtifactStatus::Clean);
676    }
677
678    #[test]
679    fn service_definitions_report_files_and_orphans() {
680        let dir = temp_dir("servicedef");
681        std::fs::write(dir.join("svc.servicedef"), b"x").unwrap();
682        std::fs::write(dir.join("stray.txt"), b"x").unwrap();
683        let checks = check_service_definitions(&dir);
684        assert_eq!(checks[0].status, ArtifactStatus::Present);
685        assert!(checks[0].detail.contains("1 .servicedef"));
686        let orphan = checks
687            .iter()
688            .find(|check| check.status == ArtifactStatus::Orphaned)
689            .expect("stray file flagged");
690        assert!(orphan.location.contains("stray.txt"));
691        let _ = std::fs::remove_dir_all(&dir);
692    }
693
694    #[test]
695    fn service_definition_dir_absent_is_clean() {
696        let dir = temp_dir("servicedef-absent");
697        let checks = check_service_definitions(&dir.join("missing"));
698        assert_eq!(checks.len(), 1);
699        assert_eq!(checks[0].status, ArtifactStatus::Clean);
700        let _ = std::fs::remove_dir_all(&dir);
701    }
702
703    #[test]
704    fn database_and_sidecars_reconcile_against_liveness() {
705        let dir = temp_dir("db");
706        let db = dir.join("tracked-pids.sqlite3");
707        assert_eq!(check_database(&db, false)[0].status, ArtifactStatus::Clean);
708
709        std::fs::write(&db, b"db").unwrap();
710        std::fs::write(dir.join("tracked-pids.sqlite3-wal"), b"wal").unwrap();
711        std::fs::write(dir.join("tracked-pids.sqlite3-shm"), b"shm").unwrap();
712
713        let dead = check_database(&db, false);
714        assert_eq!(dead[0].status, ArtifactStatus::Present);
715        assert_eq!(dead.len(), 3);
716        assert!(dead[1..]
717            .iter()
718            .all(|check| check.status == ArtifactStatus::Stale));
719
720        let alive = check_database(&db, true);
721        assert!(alive[1..]
722            .iter()
723            .all(|check| check.status == ArtifactStatus::Active));
724        let _ = std::fs::remove_dir_all(&dir);
725    }
726
727    #[test]
728    fn logs_counted_not_deleted() {
729        let dir = temp_dir("logs");
730        assert_eq!(check_logs(&dir).status, ArtifactStatus::Clean);
731        std::fs::write(dir.join("daemon.log"), b"0123456789").unwrap();
732        let check = check_logs(&dir);
733        assert_eq!(check.status, ArtifactStatus::Present);
734        assert!(check.detail.contains("1 *.log file(s), 10 bytes"));
735        assert!(dir.join("daemon.log").exists());
736        let _ = std::fs::remove_dir_all(&dir);
737    }
738
739    #[test]
740    fn emergency_reserve_states() {
741        let dir = temp_dir("reserve");
742        let path = dir.join(EMERGENCY_RESERVE_FILE_NAME);
743        assert_eq!(
744            check_emergency_reserve(&path, 1024).status,
745            ArtifactStatus::Clean
746        );
747        std::fs::write(&path, vec![0u8; 1024]).unwrap();
748        assert_eq!(
749            check_emergency_reserve(&path, 1024).status,
750            ArtifactStatus::Present
751        );
752        let check = check_emergency_reserve(&path, 2048);
753        assert_eq!(check.status, ArtifactStatus::Stale);
754        assert!(check.detail.contains("1024"));
755        let _ = std::fs::remove_dir_all(&dir);
756    }
757
758    #[test]
759    fn shadow_dir_states() {
760        let dir = temp_dir("shadow");
761        assert_eq!(
762            check_shadow_dir(&dir.join("missing"), false).status,
763            ArtifactStatus::Clean
764        );
765        assert_eq!(check_shadow_dir(&dir, false).status, ArtifactStatus::Clean);
766        std::fs::write(dir.join("daemon-abc123.exe"), b"x").unwrap();
767        assert_eq!(
768            check_shadow_dir(&dir, false).status,
769            ArtifactStatus::Present
770        );
771        assert_eq!(check_shadow_dir(&dir, true).status, ArtifactStatus::Active);
772        let _ = std::fs::remove_dir_all(&dir);
773    }
774
775    #[test]
776    fn report_exit_code_and_findings() {
777        let mut report = ArtifactReport::default();
778        report.checks.push(ArtifactCheck::new(
779            "pid-file",
780            "p",
781            ArtifactStatus::Stale,
782            "d",
783        ));
784        assert_eq!(report.finding_count(), 1);
785        assert_eq!(report.exit_code(), 0);
786        report
787            .checks
788            .push(ArtifactCheck::new("logs", "p", ArtifactStatus::Error, "d"));
789        assert_eq!(report.exit_code(), 1);
790        let json = report.to_json_value();
791        assert_eq!(json["schema_version"], 1);
792        assert_eq!(json["findings"], 2);
793        assert_eq!(json["checks"].as_array().unwrap().len(), 2);
794        let text = report.render_text();
795        assert!(text.contains("cleanup verify: 2 location(s) — 2 finding(s)"));
796    }
797
798    #[test]
799    fn from_environment_creates_nothing() {
800        // Read-only contract: deriving paths must not create directories.
801        let paths = ArtifactPaths::from_environment(Some("0123456789abcdef"));
802        assert!(paths
803            .pid_file
804            .to_string_lossy()
805            .contains("0123456789abcdef"));
806        assert!(paths.db.to_string_lossy().contains("0123456789abcdef"));
807    }
808
809    #[cfg(feature = "daemon")]
810    #[test]
811    fn reserve_constants_match_daemon_module() {
812        assert_eq!(
813            EMERGENCY_RESERVE_FILE_NAME,
814            crate::daemon::emergency_reserve::EMERGENCY_RESERVE_FILE_NAME
815        );
816        assert_eq!(
817            EMERGENCY_RESERVE_BYTES,
818            crate::daemon::emergency_reserve::EMERGENCY_RESERVE_BYTES
819        );
820    }
821}