Skip to main content

presolve_cli/
cache_commands.rs

1//! L9-E cache operations delegated to the frozen L6/L4 service boundary.
2//!
3//! Cache locations are derived only from an explicit configuration path. This
4//! module neither searches for projects nor reads source files.
5
6#![allow(clippy::missing_errors_doc, clippy::missing_panics_doc)]
7
8use std::fmt;
9use std::path::Path;
10
11use presolve_compiler::persistent_cache::CacheInspectionReportV1;
12use presolve_compiler::platform::ContractVersion;
13use presolve_compiler::service::CompilerServiceHost;
14
15use crate::load_explicit_project_envelope_v1;
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum CliCacheOperationV1 {
19    Inspect,
20    Verify,
21    Clean,
22}
23
24impl CliCacheOperationV1 {
25    #[must_use]
26    pub const fn as_str(self) -> &'static str {
27        match self {
28            Self::Inspect => "inspect",
29            Self::Verify => "verify",
30            Self::Clean => "clean",
31        }
32    }
33}
34
35#[derive(Debug, Clone)]
36pub struct CliCacheOperationResultV1 {
37    pub operation: CliCacheOperationV1,
38    pub report: Option<CacheInspectionReportV1>,
39    pub removed_keys: Vec<String>,
40}
41
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct CliCacheOperationErrorV1 {
44    pub code: &'static str,
45    pub message: String,
46}
47
48impl fmt::Display for CliCacheOperationErrorV1 {
49    fn fmt(&self, output: &mut fmt::Formatter<'_>) -> fmt::Result {
50        write!(output, "{}: {}", self.code, self.message)
51    }
52}
53
54impl std::error::Error for CliCacheOperationErrorV1 {}
55
56fn compiler_contract() -> ContractVersion {
57    ContractVersion::new(format!("presolve-compiler:{}", env!("CARGO_PKG_VERSION")))
58}
59
60/// Operates on the sole L6 cache root associated with one caller-named public
61/// configuration file. L6 owns cache validation and deletion semantics.
62pub fn run_project_cache_operation_v1(
63    configuration_path: &Path,
64    operation: CliCacheOperationV1,
65) -> Result<CliCacheOperationResultV1, CliCacheOperationErrorV1> {
66    let project_root = configuration_path
67        .parent()
68        .ok_or(CliCacheOperationErrorV1 {
69            code: "L9E001_CONFIGURATION_PATH_INVALID",
70            message: "configuration path must have a parent directory".into(),
71        })?;
72    let envelope =
73        load_explicit_project_envelope_v1(project_root, configuration_path).map_err(|error| {
74            CliCacheOperationErrorV1 {
75                code: error.code,
76                message: error.message,
77            }
78        })?;
79    let service_root = envelope.project_root.join(".presolve");
80    let cache_root = service_root.join("cache");
81    let service = CompilerServiceHost::start_with_cache(
82        &service_root,
83        Some(&cache_root),
84        compiler_contract(),
85    )
86    .map_err(|error| CliCacheOperationErrorV1 {
87        code: error.code,
88        message: error.message,
89    })?;
90    match operation {
91        CliCacheOperationV1::Inspect => {
92            service
93                .inspect_cache(&cache_root)
94                .map(|report| CliCacheOperationResultV1 {
95                    operation,
96                    report: Some(report),
97                    removed_keys: Vec::new(),
98                })
99        }
100        CliCacheOperationV1::Verify => {
101            service
102                .verify_cache(&cache_root)
103                .map(|report| CliCacheOperationResultV1 {
104                    operation,
105                    report: Some(report),
106                    removed_keys: Vec::new(),
107                })
108        }
109        CliCacheOperationV1::Clean => {
110            service
111                .clean_cache(&cache_root)
112                .map(|removed_keys| CliCacheOperationResultV1 {
113                    operation,
114                    report: None,
115                    removed_keys,
116                })
117        }
118    }
119    .map_err(|error| CliCacheOperationErrorV1 {
120        code: error.code,
121        message: error.message,
122    })
123}
124
125#[cfg(test)]
126mod tests {
127    use std::fs;
128    use std::sync::atomic::{AtomicU64, Ordering};
129
130    use super::*;
131
132    static NEXT_ROOT: AtomicU64 = AtomicU64::new(1);
133
134    fn setup() -> (std::path::PathBuf, std::path::PathBuf) {
135        let root = std::env::temp_dir().join(format!(
136            "presolve-l9e-{}",
137            NEXT_ROOT.fetch_add(1, Ordering::Relaxed)
138        ));
139        if root.exists() {
140            fs::remove_dir_all(&root).unwrap();
141        }
142        fs::create_dir_all(&root).unwrap();
143        let config = root.join("presolve.json");
144        fs::write(
145            &config,
146            include_bytes!("../fixtures/configuration/minimum-cli-v1.json"),
147        )
148        .unwrap();
149        (root, config)
150    }
151
152    #[test]
153    fn l9e_inspects_and_verifies_only_the_configured_l6_cache_root() {
154        let (root, config) = setup();
155        fs::write(root.join("outside.txt"), "must survive").unwrap();
156        let inspection = run_project_cache_operation_v1(&config, CliCacheOperationV1::Inspect)
157            .unwrap()
158            .report
159            .unwrap();
160        assert!(inspection.enabled);
161        assert!(inspection.manifest_valid);
162        let verification = run_project_cache_operation_v1(&config, CliCacheOperationV1::Verify)
163            .unwrap()
164            .report
165            .unwrap();
166        assert_eq!(
167            inspection.to_canonical_json(),
168            verification.to_canonical_json()
169        );
170        assert_eq!(
171            fs::read_to_string(root.join("outside.txt")).unwrap(),
172            "must survive"
173        );
174        fs::remove_dir_all(root).unwrap();
175    }
176
177    #[test]
178    fn l9e_clean_delegates_to_l6_and_never_removes_project_siblings() {
179        let (root, config) = setup();
180        fs::write(root.join("outside.txt"), "must survive").unwrap();
181        run_project_cache_operation_v1(&config, CliCacheOperationV1::Inspect).unwrap();
182        let result = run_project_cache_operation_v1(&config, CliCacheOperationV1::Clean).unwrap();
183        assert!(result.removed_keys.is_empty());
184        assert!(root.join(".presolve/cache/entries").is_dir());
185        assert_eq!(
186            fs::read_to_string(root.join("outside.txt")).unwrap(),
187            "must survive"
188        );
189        fs::remove_dir_all(root).unwrap();
190    }
191}