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