Skip to main content

provenant/cache/
config.rs

1// SPDX-FileCopyrightText: Provenant contributors
2// SPDX-License-Identifier: Apache-2.0
3
4use std::fs;
5use std::io;
6use std::path::{Path, PathBuf};
7
8use directories::ProjectDirs;
9
10use super::locking::scans_lock_path;
11
12pub const DEFAULT_CACHE_DIR_NAME: &str = ".provenant-cache";
13pub const CACHE_DIR_ENV_VAR: &str = "PROVENANT_CACHE";
14
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub struct CacheConfig {
17    root_dir: PathBuf,
18    incremental: bool,
19    trust_mtime: bool,
20}
21
22impl CacheConfig {
23    #[cfg(test)]
24    pub fn new(root_dir: PathBuf) -> Self {
25        Self {
26            root_dir,
27            incremental: false,
28            trust_mtime: false,
29        }
30    }
31
32    pub fn with_options(root_dir: PathBuf, incremental: bool, trust_mtime: bool) -> Self {
33        Self {
34            root_dir,
35            incremental,
36            trust_mtime,
37        }
38    }
39
40    #[cfg(test)]
41    pub fn from_scan_root(scan_root: &Path) -> Self {
42        Self::new(scan_root.join(DEFAULT_CACHE_DIR_NAME))
43    }
44
45    pub(crate) fn project_cache_root() -> Option<PathBuf> {
46        ProjectDirs::from("com", "Provenant", "provenant")
47            .map(|dirs| dirs.cache_dir().to_path_buf())
48    }
49
50    pub fn default_root_dir(scan_root: &Path) -> PathBuf {
51        Self::project_cache_root().unwrap_or_else(|| scan_root.join(DEFAULT_CACHE_DIR_NAME))
52    }
53
54    pub fn default_root_dir_without_scan_root() -> PathBuf {
55        Self::project_cache_root().unwrap_or_else(|| PathBuf::from(DEFAULT_CACHE_DIR_NAME))
56    }
57
58    pub fn resolve_root_dir(
59        scan_root: Option<&Path>,
60        cli_cache_dir: Option<&Path>,
61        env_cache_dir: Option<&Path>,
62    ) -> PathBuf {
63        if let Some(path) = cli_cache_dir {
64            return path.to_path_buf();
65        }
66
67        if let Some(path) = env_cache_dir {
68            return path.to_path_buf();
69        }
70
71        match scan_root {
72            Some(scan_root) => Self::default_root_dir(scan_root),
73            None => Self::default_root_dir_without_scan_root(),
74        }
75    }
76
77    pub fn from_overrides(
78        scan_root: Option<&Path>,
79        cli_cache_dir: Option<&Path>,
80        env_cache_dir: Option<&Path>,
81        incremental: bool,
82        trust_mtime: bool,
83    ) -> Self {
84        Self::with_options(
85            Self::resolve_root_dir(scan_root, cli_cache_dir, env_cache_dir),
86            incremental,
87            trust_mtime,
88        )
89    }
90
91    pub fn root_dir(&self) -> &Path {
92        &self.root_dir
93    }
94
95    pub fn incremental_dir(&self) -> PathBuf {
96        self.root_dir.join("incremental")
97    }
98
99    pub const fn incremental_enabled(&self) -> bool {
100        self.incremental
101    }
102
103    /// When `true`, warm incremental scans accept a cached entry on a size +
104    /// mtime fingerprint match without re-reading and re-hashing the file.
105    pub const fn trust_mtime(&self) -> bool {
106        self.trust_mtime
107    }
108
109    pub fn ensure_dirs(&self) -> io::Result<()> {
110        if self.incremental_enabled() {
111            // Incremental state can contain file paths from private repositories,
112            // so keep the cache tree owner-only on Unix.
113            super::create_dir_all_private(&self.incremental_dir())?;
114        }
115        Ok(())
116    }
117
118    #[cfg(test)]
119    pub fn clear(&self) -> io::Result<()> {
120        if self.root_dir().exists() {
121            fs::remove_dir_all(&self.root_dir)?;
122        }
123        Ok(())
124    }
125
126    pub fn clear_contents(&self) -> io::Result<()> {
127        if !self.root_dir().exists() {
128            return Ok(());
129        }
130
131        let lock_path = scans_lock_path(self.root_dir());
132        for entry in fs::read_dir(self.root_dir())? {
133            let entry = entry?;
134            let path = entry.path();
135            if path == lock_path {
136                continue;
137            }
138
139            if path.is_dir() {
140                fs::remove_dir_all(path)?;
141            } else {
142                fs::remove_file(path)?;
143            }
144        }
145
146        Ok(())
147    }
148}
149
150#[cfg(test)]
151mod tests {
152    use tempfile::TempDir;
153
154    use super::*;
155
156    #[test]
157    fn test_from_scan_root_uses_expected_directory_name() {
158        let temp_dir = TempDir::new().expect("Failed to create temp dir");
159        let config = CacheConfig::from_scan_root(temp_dir.path());
160        assert_eq!(
161            config.root_dir(),
162            temp_dir.path().join(DEFAULT_CACHE_DIR_NAME)
163        );
164    }
165
166    #[test]
167    fn test_ensure_dirs_creates_expected_tree() {
168        let temp_dir = TempDir::new().expect("Failed to create temp dir");
169        let config =
170            CacheConfig::with_options(temp_dir.path().join(DEFAULT_CACHE_DIR_NAME), true, false);
171
172        config
173            .ensure_dirs()
174            .expect("Failed to create cache directories");
175
176        assert!(config.root_dir().exists());
177        assert!(config.incremental_dir().exists());
178    }
179
180    #[test]
181    fn test_resolve_root_dir_prefers_cli_then_env_then_default() {
182        let scan_root = Path::new("/scan-root");
183        let cli_dir = Path::new("/cli-cache");
184        let env_dir = Path::new("/env-cache");
185
186        assert_eq!(
187            CacheConfig::resolve_root_dir(Some(scan_root), Some(cli_dir), Some(env_dir)),
188            cli_dir
189        );
190        assert_eq!(
191            CacheConfig::resolve_root_dir(Some(scan_root), None, Some(env_dir)),
192            env_dir
193        );
194        assert_eq!(
195            CacheConfig::resolve_root_dir(Some(scan_root), None, None),
196            CacheConfig::default_root_dir(scan_root)
197        );
198    }
199
200    #[test]
201    fn test_resolve_root_dir_without_scan_root_uses_project_or_relative_default() {
202        assert_eq!(
203            CacheConfig::resolve_root_dir(None, None, None),
204            CacheConfig::default_root_dir_without_scan_root()
205        );
206    }
207
208    #[test]
209    fn test_clear_removes_cache_root_directory() {
210        let temp_dir = TempDir::new().expect("Failed to create temp dir");
211        let config = CacheConfig::with_options(temp_dir.path().join("cache-root"), true, false);
212
213        config
214            .ensure_dirs()
215            .expect("Failed to create cache directories");
216        assert!(config.root_dir().exists());
217
218        config.clear().expect("Failed to clear cache directory");
219        assert!(!config.root_dir().exists());
220    }
221}