Skip to main content

cfgd_core/providers/
mod.rs

1// Provider traits and registry — consumed by packages/, files/, secrets/, reconciler/
2
3use std::collections::HashSet;
4use std::path::{Path, PathBuf};
5
6use secrecy::SecretString;
7use serde::{Deserialize, Serialize};
8
9use crate::errors::Result;
10use crate::output::Printer;
11
12// --- PackageManager trait ---
13
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct PackageInfo {
16    pub name: String,
17    pub version: String,
18}
19
20pub trait PackageManager: Send + Sync {
21    fn name(&self) -> &str;
22    fn is_available(&self) -> bool;
23    fn can_bootstrap(&self) -> bool;
24    fn bootstrap(&self, printer: &Printer) -> Result<()>;
25    fn installed_packages(&self) -> Result<HashSet<String>>;
26    fn install(&self, packages: &[String], printer: &Printer) -> Result<()>;
27    fn uninstall(&self, packages: &[String], printer: &Printer) -> Result<()>;
28    fn update(&self, printer: &Printer) -> Result<()>;
29
30    /// Query the available version of a package without installing it.
31    /// Returns None if the package is not found in the manager's index.
32    fn available_version(&self, package: &str) -> Result<Option<String>>;
33
34    /// Directories to add to PATH after bootstrap. Empty for managers
35    /// that are already on the system PATH (apt, dnf, etc.).
36    fn path_dirs(&self) -> Vec<String> {
37        Vec::new()
38    }
39
40    /// List all installed packages with their installed versions.
41    /// Default implementation wraps `installed_packages()` with version "unknown".
42    fn installed_packages_with_versions(&self) -> Result<Vec<PackageInfo>> {
43        Ok(self
44            .installed_packages()?
45            .into_iter()
46            .map(|name| PackageInfo {
47                name,
48                version: "unknown".into(),
49            })
50            .collect())
51    }
52
53    /// Return alternative names / aliases for a canonical package name.
54    /// Used for cross-manager package resolution. Default returns empty.
55    fn package_aliases(&self, _canonical_name: &str) -> Result<Vec<String>> {
56        Ok(vec![])
57    }
58}
59
60// --- SystemConfigurator trait ---
61
62pub struct SystemDrift {
63    pub key: String,
64    pub expected: String,
65    pub actual: String,
66}
67
68pub trait SystemConfigurator: Send + Sync {
69    /// Configurator name — must match the key in the profile's `system:` map
70    fn name(&self) -> &str;
71    fn is_available(&self) -> bool;
72
73    /// Read current state from the system
74    fn current_state(&self) -> Result<serde_yaml::Value>;
75
76    /// Diff desired vs actual, return list of changes
77    fn diff(&self, desired: &serde_yaml::Value) -> Result<Vec<SystemDrift>>;
78
79    /// Apply desired state
80    fn apply(&self, desired: &serde_yaml::Value, printer: &Printer) -> Result<()>;
81}
82
83// --- FileManager trait ---
84
85use std::collections::BTreeMap;
86
87#[derive(Debug)]
88pub struct FileLayer {
89    pub source_dir: PathBuf,
90    pub origin_source: String,
91    pub priority: u32,
92}
93
94#[derive(Debug)]
95pub struct FileTree {
96    pub files: BTreeMap<PathBuf, FileEntry>,
97}
98
99#[derive(Debug)]
100pub struct FileEntry {
101    pub content_hash: String,
102    pub permissions: Option<u32>,
103    pub is_template: bool,
104    pub source_path: PathBuf,
105    pub origin_source: String,
106}
107
108#[derive(Debug)]
109pub struct FileDiff {
110    pub target: PathBuf,
111    pub kind: FileDiffKind,
112}
113
114#[derive(Debug)]
115pub enum FileDiffKind {
116    Created { source: PathBuf },
117    Modified { source: PathBuf, diff: String },
118    Deleted,
119    PermissionsChanged { current: u32, desired: u32 },
120    Unchanged,
121}
122
123#[derive(Debug, Serialize)]
124pub enum FileAction {
125    Create {
126        source: PathBuf,
127        target: PathBuf,
128        origin: String,
129        strategy: crate::config::FileStrategy,
130        /// SHA256 of source content at plan time (for TOCTOU verification).
131        source_hash: Option<String>,
132    },
133    Update {
134        source: PathBuf,
135        target: PathBuf,
136        diff: String,
137        origin: String,
138        strategy: crate::config::FileStrategy,
139        /// SHA256 of source content at plan time (for TOCTOU verification).
140        source_hash: Option<String>,
141    },
142    Delete {
143        target: PathBuf,
144        origin: String,
145    },
146    SetPermissions {
147        target: PathBuf,
148        mode: u32,
149        origin: String,
150    },
151    Skip {
152        target: PathBuf,
153        reason: String,
154        origin: String,
155    },
156}
157
158pub trait FileManager: Send + Sync {
159    fn scan_source(&self, layers: &[FileLayer]) -> Result<FileTree>;
160    fn scan_target(&self, paths: &[PathBuf]) -> Result<FileTree>;
161    fn diff(&self, source: &FileTree, target: &FileTree) -> Result<Vec<FileDiff>>;
162    fn apply(&self, actions: &[FileAction], printer: &Printer) -> Result<()>;
163}
164
165// --- PackageAction ---
166
167#[derive(Debug, Serialize)]
168pub enum PackageAction {
169    Bootstrap {
170        manager: String,
171        method: String,
172        origin: String,
173    },
174    Install {
175        manager: String,
176        packages: Vec<String>,
177        origin: String,
178    },
179    Uninstall {
180        manager: String,
181        packages: Vec<String>,
182        origin: String,
183    },
184    Skip {
185        manager: String,
186        reason: String,
187        origin: String,
188    },
189}
190
191// --- SecretBackend trait ---
192
193pub trait SecretBackend: Send + Sync {
194    fn name(&self) -> &str;
195    fn is_available(&self) -> bool;
196    fn encrypt_file(&self, path: &Path) -> Result<()>;
197    fn decrypt_file(&self, path: &Path) -> Result<SecretString>;
198    fn edit_file(&self, path: &Path) -> Result<()>;
199}
200
201// --- SecretProvider trait ---
202
203pub trait SecretProvider: Send + Sync {
204    fn name(&self) -> &str;
205    fn is_available(&self) -> bool;
206    fn resolve(&self, reference: &str) -> Result<SecretString>;
207}
208
209// --- SecretAction ---
210
211#[derive(Debug, Serialize)]
212pub enum SecretAction {
213    Decrypt {
214        source: PathBuf,
215        target: PathBuf,
216        backend: String,
217        origin: String,
218    },
219    Resolve {
220        provider: String,
221        reference: String,
222        target: PathBuf,
223        origin: String,
224    },
225    /// Resolve a secret and inject its value as environment variables into the
226    /// managed shell env file (`~/.cfgd.env`, `~/.cfgd-env.ps1`, fish conf.d).
227    ResolveEnv {
228        provider: String,
229        reference: String,
230        envs: Vec<String>,
231        origin: String,
232    },
233    Skip {
234        source: String,
235        reason: String,
236        origin: String,
237    },
238}
239
240// --- ProviderRegistry ---
241
242pub struct ProviderRegistry {
243    pub package_managers: Vec<Box<dyn PackageManager>>,
244    pub system_configurators: Vec<Box<dyn SystemConfigurator>>,
245    pub file_manager: Option<Box<dyn FileManager>>,
246    pub secret_backend: Option<Box<dyn SecretBackend>>,
247    pub secret_providers: Vec<Box<dyn SecretProvider>>,
248    pub default_file_strategy: crate::config::FileStrategy,
249}
250
251impl ProviderRegistry {
252    pub fn new() -> Self {
253        Self {
254            package_managers: Vec::new(),
255            system_configurators: Vec::new(),
256            file_manager: None,
257            secret_backend: None,
258            secret_providers: Vec::new(),
259            default_file_strategy: crate::config::FileStrategy::Symlink,
260        }
261    }
262
263    pub fn available_package_managers(&self) -> Vec<&dyn PackageManager> {
264        self.package_managers
265            .iter()
266            .filter(|pm| pm.is_available())
267            .map(|pm| pm.as_ref())
268            .collect()
269    }
270
271    pub fn available_system_configurators(&self) -> Vec<&dyn SystemConfigurator> {
272        self.system_configurators
273            .iter()
274            .filter(|sc| sc.is_available())
275            .map(|sc| sc.as_ref())
276            .collect()
277    }
278}
279
280impl Default for ProviderRegistry {
281    fn default() -> Self {
282        Self::new()
283    }
284}
285
286/// Parse a secret reference string to determine the provider.
287/// Formats:
288///   - `1password://Vault/Item/Field` → 1Password
289///   - `bitwarden://folder/item` → Bitwarden
290///   - `lastpass://folder/item/field` → LastPass
291///   - `vault://secret/path#field` → HashiCorp Vault
292///
293/// Returns (provider_name, reference_path).
294pub fn parse_secret_reference(source: &str) -> Option<(&str, &str)> {
295    if let Some(rest) = source.strip_prefix("1password://") {
296        Some(("1password", rest))
297    } else if let Some(rest) = source.strip_prefix("bitwarden://") {
298        Some(("bitwarden", rest))
299    } else if let Some(rest) = source.strip_prefix("lastpass://") {
300        Some(("lastpass", rest))
301    } else if let Some(rest) = source.strip_prefix("vault://") {
302        Some(("vault", rest))
303    } else {
304        None
305    }
306}
307
308/// Configurable mock for `PackageManager`. Available to all test modules within cfgd-core.
309#[cfg(test)]
310pub(crate) struct StubPackageManager {
311    pub name: String,
312    pub available: bool,
313    pub installed: HashSet<String>,
314    pub versions: std::collections::HashMap<String, String>,
315    pub bootstrap_capable: bool,
316}
317
318#[cfg(test)]
319impl StubPackageManager {
320    pub fn new(name: &str) -> Self {
321        Self {
322            name: name.to_string(),
323            available: true,
324            installed: HashSet::new(),
325            versions: std::collections::HashMap::new(),
326            bootstrap_capable: false,
327        }
328    }
329
330    pub fn unavailable(mut self) -> Self {
331        self.available = false;
332        self
333    }
334
335    pub fn bootstrappable(mut self) -> Self {
336        self.bootstrap_capable = true;
337        self
338    }
339
340    pub fn with_installed(mut self, pkgs: &[&str]) -> Self {
341        for p in pkgs {
342            self.installed.insert((*p).to_string());
343        }
344        self
345    }
346
347    pub fn with_package(mut self, pkg: &str, ver: &str) -> Self {
348        self.versions.insert(pkg.to_string(), ver.to_string());
349        self
350    }
351}
352
353#[cfg(test)]
354impl PackageManager for StubPackageManager {
355    fn name(&self) -> &str {
356        &self.name
357    }
358    fn is_available(&self) -> bool {
359        self.available
360    }
361    fn can_bootstrap(&self) -> bool {
362        self.bootstrap_capable
363    }
364    fn bootstrap(&self, _printer: &Printer) -> Result<()> {
365        Ok(())
366    }
367    fn installed_packages(&self) -> Result<HashSet<String>> {
368        Ok(self.installed.clone())
369    }
370    fn install(&self, _packages: &[String], _printer: &Printer) -> Result<()> {
371        Ok(())
372    }
373    fn uninstall(&self, _packages: &[String], _printer: &Printer) -> Result<()> {
374        Ok(())
375    }
376    fn update(&self, _printer: &Printer) -> Result<()> {
377        Ok(())
378    }
379    fn available_version(&self, package: &str) -> Result<Option<String>> {
380        Ok(self.versions.get(package).cloned())
381    }
382}
383
384#[cfg(test)]
385mod tests {
386    use super::*;
387
388    #[test]
389    fn registry_filters_available_managers() {
390        let mut registry = ProviderRegistry::new();
391        registry
392            .package_managers
393            .push(Box::new(StubPackageManager::new("mock")));
394        registry
395            .package_managers
396            .push(Box::new(StubPackageManager::new("mock2").unavailable()));
397
398        let available = registry.available_package_managers();
399        assert_eq!(available.len(), 1);
400        assert_eq!(available[0].name(), "mock");
401    }
402
403    #[test]
404    fn empty_registry() {
405        let registry = ProviderRegistry::new();
406        assert!(registry.available_package_managers().is_empty());
407        assert!(registry.available_system_configurators().is_empty());
408        assert!(registry.file_manager.is_none());
409        assert!(registry.secret_backend.is_none());
410    }
411
412    #[test]
413    fn test_default_installed_packages_with_versions_empty() {
414        let mock = StubPackageManager::new("mock");
415        let pkgs = mock.installed_packages_with_versions().unwrap();
416        assert!(pkgs.is_empty());
417    }
418
419    #[test]
420    fn test_default_package_aliases_empty() {
421        let mock = StubPackageManager::new("mock");
422        let aliases = mock.package_aliases("fd").unwrap();
423        assert!(aliases.is_empty());
424    }
425}