Skip to main content

running_process/cleanup/
mod.rs

1//! Standalone cleanup support for v1 broker CacheManifest files.
2//!
3//! Phase 2 of #228 (#231). This module intentionally operates only on
4//! the central manifest registry; it does not require the broker or any
5//! originating daemon to be running.
6
7use std::path::{Path, PathBuf};
8use std::time::{SystemTime, UNIX_EPOCH};
9
10use crate::broker::protocol::{CacheManifest, CacheRoot, CacheRootKind, StorageDisposition};
11
12/// Inspection helpers for broker cleanup instance metadata.
13pub mod instances;
14/// Read-only cleanup listing operations.
15pub mod list;
16/// Pruning operations for removable cache roots.
17pub mod prune;
18/// Uninstall cleanup operations for service-owned roots.
19pub mod uninstall;
20/// Exhaustive daemon-artifact reconciliation for `cleanup verify` (#391).
21pub mod verify_artifacts;
22/// Basic cleanup verification helpers.
23pub mod verify_basic;
24
25/// A filesystem action planned or executed by cleanup.
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct CleanupAction {
28    /// Manifest service name.
29    pub service_name: String,
30    /// Manifest service version.
31    pub service_version: String,
32    /// Root path affected by the action.
33    pub path: PathBuf,
34    /// Why the path was selected.
35    pub reason: String,
36    /// Whether the path was deleted.
37    pub deleted: bool,
38    /// Whether the path was skipped.
39    pub skipped: bool,
40    /// Skip reason when `skipped` is true.
41    pub skip_reason: Option<String>,
42}
43
44/// Shared cleanup error type.
45#[derive(Debug, thiserror::Error)]
46pub enum CleanupError {
47    /// Manifest-layer error.
48    #[error(transparent)]
49    Manifest(#[from] crate::broker::manifest::ManifestError),
50    /// Filesystem operation failed.
51    #[error("cleanup I/O failed: {0}")]
52    Io(#[from] std::io::Error),
53    /// User supplied an invalid argument.
54    #[error("{0}")]
55    User(String),
56}
57
58/// Current wall-clock time as Unix milliseconds.
59pub fn now_unix_ms() -> u64 {
60    SystemTime::now()
61        .duration_since(UNIX_EPOCH)
62        .map(|d| d.as_millis() as u64)
63        .unwrap_or(0)
64}
65
66/// Parse simple duration strings such as `30d`, `12h`, `10m`, `45s`.
67pub fn parse_duration_secs(input: &str) -> Result<u64, CleanupError> {
68    if input.is_empty() {
69        return Err(CleanupError::User("duration must not be empty".into()));
70    }
71    let (digits, suffix) = input.split_at(input.len() - 1);
72    let value: u64 = digits
73        .parse()
74        .map_err(|_| CleanupError::User(format!("invalid duration: {input}")))?;
75    match suffix {
76        "d" => Ok(value * 24 * 60 * 60),
77        "h" => Ok(value * 60 * 60),
78        "m" => Ok(value * 60),
79        "s" => Ok(value),
80        _ => Err(CleanupError::User(format!(
81            "duration must end with d, h, m, or s: {input}"
82        ))),
83    }
84}
85
86pub(crate) fn root_disposition(root: &CacheRoot) -> i32 {
87    root.disposition
88}
89
90pub(crate) fn root_kind(root: &CacheRoot) -> i32 {
91    root.kind
92}
93
94pub(crate) fn root_is_config(root: &CacheRoot) -> bool {
95    root_kind(root) == CacheRootKind::CacheConfig as i32
96}
97
98pub(crate) fn root_is_prunable(root: &CacheRoot) -> bool {
99    !matches!(
100        root_disposition(root),
101        x if x == StorageDisposition::NeverPrune as i32
102            || x == StorageDisposition::PreserveAcrossUninstall as i32
103    )
104}
105
106pub(crate) fn delete_path(path: &Path) -> Result<(), CleanupError> {
107    match std::fs::symlink_metadata(path) {
108        Ok(meta) if meta.is_dir() => std::fs::remove_dir_all(path)?,
109        Ok(_) => std::fs::remove_file(path)?,
110        Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
111        Err(err) => return Err(CleanupError::Io(err)),
112    }
113    Ok(())
114}
115
116pub(crate) fn json_escape(s: &str) -> String {
117    let mut out = String::with_capacity(s.len());
118    for c in s.chars() {
119        match c {
120            '"' => out.push_str("\\\""),
121            '\\' => out.push_str("\\\\"),
122            '\n' => out.push_str("\\n"),
123            '\r' => out.push_str("\\r"),
124            '\t' => out.push_str("\\t"),
125            c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
126            c => out.push(c),
127        }
128    }
129    out
130}
131
132pub(crate) fn manifest_json(manifest: &CacheManifest) -> String {
133    let roots = manifest
134        .roots
135        .iter()
136        .map(|root| {
137            format!(
138                "{{\"path\":\"{}\",\"kind\":{},\"disposition\":{},\"estimated_size_bytes\":{}}}",
139                json_escape(&root.path),
140                root.kind,
141                root.disposition,
142                root.estimated_size_bytes
143            )
144        })
145        .collect::<Vec<_>>()
146        .join(",");
147    format!(
148        "{{\"service_name\":\"{}\",\"service_version\":\"{}\",\"broker_instance\":\"{}\",\"last_active_unix_ms\":{},\"roots\":[{}]}}",
149        json_escape(&manifest.service_name),
150        json_escape(&manifest.service_version),
151        json_escape(&manifest.broker_instance),
152        manifest.last_active_unix_ms,
153        roots
154    )
155}
156
157/// Serialize cleanup actions to the CLI JSON response envelope.
158pub fn actions_json(schema_version: u32, actions: &[CleanupAction]) -> String {
159    let actions = actions
160        .iter()
161        .map(|action| {
162            format!(
163                "{{\"service_name\":\"{}\",\"service_version\":\"{}\",\"path\":\"{}\",\"reason\":\"{}\",\"deleted\":{},\"skipped\":{},\"skip_reason\":{}}}",
164                json_escape(&action.service_name),
165                json_escape(&action.service_version),
166                json_escape(&action.path.to_string_lossy()),
167                json_escape(&action.reason),
168                action.deleted,
169                action.skipped,
170                match &action.skip_reason {
171                    Some(reason) => format!("\"{}\"", json_escape(reason)),
172                    None => "null".to_string(),
173                }
174            )
175        })
176        .collect::<Vec<_>>()
177        .join(",");
178    format!("{{\"schema_version\":{schema_version},\"actions\":[{actions}]}}")
179}