use std::collections::HashSet;
use std::path::{Path, PathBuf};
use secrecy::SecretString;
use serde::{Deserialize, Serialize};
use crate::errors::Result;
use crate::output::Printer;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PackageInfo {
pub name: String,
pub version: String,
}
pub trait PackageManager: Send + Sync {
fn name(&self) -> &str;
fn is_available(&self) -> bool;
fn can_bootstrap(&self) -> bool;
fn bootstrap(&self, printer: &Printer) -> Result<()>;
fn installed_packages(&self) -> Result<HashSet<String>>;
fn install(&self, packages: &[String], printer: &Printer) -> Result<()>;
fn uninstall(&self, packages: &[String], printer: &Printer) -> Result<()>;
fn update(&self, printer: &Printer) -> Result<()>;
fn available_version(&self, package: &str) -> Result<Option<String>>;
fn path_dirs(&self) -> Vec<String> {
Vec::new()
}
fn installed_packages_with_versions(&self) -> Result<Vec<PackageInfo>> {
Ok(self
.installed_packages()?
.into_iter()
.map(|name| PackageInfo {
name,
version: "unknown".into(),
})
.collect())
}
fn package_aliases(&self, _canonical_name: &str) -> Result<Vec<String>> {
Ok(vec![])
}
}
pub struct SystemDrift {
pub key: String,
pub expected: String,
pub actual: String,
}
pub trait SystemConfigurator: Send + Sync {
fn name(&self) -> &str;
fn is_available(&self) -> bool;
fn current_state(&self) -> Result<serde_yaml::Value>;
fn diff(&self, desired: &serde_yaml::Value) -> Result<Vec<SystemDrift>>;
fn apply(&self, desired: &serde_yaml::Value, printer: &Printer) -> Result<()>;
}
use std::collections::BTreeMap;
#[derive(Debug)]
pub struct FileLayer {
pub source_dir: PathBuf,
pub origin_source: String,
pub priority: u32,
}
#[derive(Debug)]
pub struct FileTree {
pub files: BTreeMap<PathBuf, FileEntry>,
}
#[derive(Debug)]
pub struct FileEntry {
pub content_hash: String,
pub permissions: Option<u32>,
pub is_template: bool,
pub source_path: PathBuf,
pub origin_source: String,
}
#[derive(Debug)]
pub struct FileDiff {
pub target: PathBuf,
pub kind: FileDiffKind,
}
#[derive(Debug)]
pub enum FileDiffKind {
Created { source: PathBuf },
Modified { source: PathBuf, diff: String },
Deleted,
PermissionsChanged { current: u32, desired: u32 },
Unchanged,
}
#[derive(Debug, Serialize)]
pub enum FileAction {
Create {
source: PathBuf,
target: PathBuf,
origin: String,
strategy: crate::config::FileStrategy,
source_hash: Option<String>,
},
Update {
source: PathBuf,
target: PathBuf,
diff: String,
origin: String,
strategy: crate::config::FileStrategy,
source_hash: Option<String>,
},
Delete {
target: PathBuf,
origin: String,
},
SetPermissions {
target: PathBuf,
mode: u32,
origin: String,
},
Skip {
target: PathBuf,
reason: String,
origin: String,
},
}
pub trait FileManager: Send + Sync {
fn scan_source(&self, layers: &[FileLayer]) -> Result<FileTree>;
fn scan_target(&self, paths: &[PathBuf]) -> Result<FileTree>;
fn diff(&self, source: &FileTree, target: &FileTree) -> Result<Vec<FileDiff>>;
fn apply(&self, actions: &[FileAction], printer: &Printer) -> Result<()>;
}
#[derive(Debug, Serialize)]
pub enum PackageAction {
Bootstrap {
manager: String,
method: String,
origin: String,
},
Install {
manager: String,
packages: Vec<String>,
origin: String,
},
Uninstall {
manager: String,
packages: Vec<String>,
origin: String,
},
Skip {
manager: String,
reason: String,
origin: String,
},
}
pub trait SecretBackend: Send + Sync {
fn name(&self) -> &str;
fn is_available(&self) -> bool;
fn encrypt_file(&self, path: &Path) -> Result<()>;
fn decrypt_file(&self, path: &Path) -> Result<SecretString>;
fn edit_file(&self, path: &Path) -> Result<()>;
}
pub trait SecretProvider: Send + Sync {
fn name(&self) -> &str;
fn is_available(&self) -> bool;
fn resolve(&self, reference: &str) -> Result<SecretString>;
}
#[derive(Debug, Serialize)]
pub enum SecretAction {
Decrypt {
source: PathBuf,
target: PathBuf,
backend: String,
origin: String,
},
Resolve {
provider: String,
reference: String,
target: PathBuf,
origin: String,
},
ResolveEnv {
provider: String,
reference: String,
envs: Vec<String>,
origin: String,
},
Skip {
source: String,
reason: String,
origin: String,
},
}
pub struct ProviderRegistry {
pub package_managers: Vec<Box<dyn PackageManager>>,
pub system_configurators: Vec<Box<dyn SystemConfigurator>>,
pub file_manager: Option<Box<dyn FileManager>>,
pub secret_backend: Option<Box<dyn SecretBackend>>,
pub secret_providers: Vec<Box<dyn SecretProvider>>,
pub default_file_strategy: crate::config::FileStrategy,
}
impl ProviderRegistry {
pub fn new() -> Self {
Self {
package_managers: Vec::new(),
system_configurators: Vec::new(),
file_manager: None,
secret_backend: None,
secret_providers: Vec::new(),
default_file_strategy: crate::config::FileStrategy::Symlink,
}
}
pub fn available_package_managers(&self) -> Vec<&dyn PackageManager> {
self.package_managers
.iter()
.filter(|pm| pm.is_available())
.map(|pm| pm.as_ref())
.collect()
}
pub fn available_system_configurators(&self) -> Vec<&dyn SystemConfigurator> {
self.system_configurators
.iter()
.filter(|sc| sc.is_available())
.map(|sc| sc.as_ref())
.collect()
}
}
impl Default for ProviderRegistry {
fn default() -> Self {
Self::new()
}
}
pub fn parse_secret_reference(source: &str) -> Option<(&str, &str)> {
if let Some(rest) = source.strip_prefix("1password://") {
Some(("1password", rest))
} else if let Some(rest) = source.strip_prefix("bitwarden://") {
Some(("bitwarden", rest))
} else if let Some(rest) = source.strip_prefix("lastpass://") {
Some(("lastpass", rest))
} else if let Some(rest) = source.strip_prefix("vault://") {
Some(("vault", rest))
} else {
None
}
}
#[cfg(test)]
pub(crate) struct StubPackageManager {
pub name: String,
pub available: bool,
pub installed: HashSet<String>,
pub versions: std::collections::HashMap<String, String>,
pub bootstrap_capable: bool,
pub installed_error: Option<String>,
}
#[cfg(test)]
impl StubPackageManager {
pub fn new(name: &str) -> Self {
Self {
name: name.to_string(),
available: true,
installed: HashSet::new(),
versions: std::collections::HashMap::new(),
bootstrap_capable: false,
installed_error: None,
}
}
pub fn unavailable(mut self) -> Self {
self.available = false;
self
}
pub fn bootstrappable(mut self) -> Self {
self.bootstrap_capable = true;
self
}
pub fn with_installed(mut self, pkgs: &[&str]) -> Self {
for p in pkgs {
self.installed.insert((*p).to_string());
}
self
}
pub fn with_installed_error(mut self, message: &str) -> Self {
self.installed_error = Some(message.to_string());
self
}
pub fn with_package(mut self, pkg: &str, ver: &str) -> Self {
self.versions.insert(pkg.to_string(), ver.to_string());
self
}
}
#[cfg(test)]
impl PackageManager for StubPackageManager {
fn name(&self) -> &str {
&self.name
}
fn is_available(&self) -> bool {
self.available
}
fn can_bootstrap(&self) -> bool {
self.bootstrap_capable
}
fn bootstrap(&self, _printer: &Printer) -> Result<()> {
Ok(())
}
fn installed_packages(&self) -> Result<HashSet<String>> {
if let Some(ref msg) = self.installed_error {
return Err(crate::errors::CfgdError::Io(std::io::Error::other(
msg.clone(),
)));
}
Ok(self.installed.clone())
}
fn install(&self, _packages: &[String], _printer: &Printer) -> Result<()> {
Ok(())
}
fn uninstall(&self, _packages: &[String], _printer: &Printer) -> Result<()> {
Ok(())
}
fn update(&self, _printer: &Printer) -> Result<()> {
Ok(())
}
fn available_version(&self, package: &str) -> Result<Option<String>> {
Ok(self.versions.get(package).cloned())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn registry_filters_available_managers() {
let mut registry = ProviderRegistry::new();
registry
.package_managers
.push(Box::new(StubPackageManager::new("mock")));
registry
.package_managers
.push(Box::new(StubPackageManager::new("mock2").unavailable()));
let available = registry.available_package_managers();
assert_eq!(available.len(), 1);
assert_eq!(available[0].name(), "mock");
}
#[test]
fn empty_registry() {
let registry = ProviderRegistry::new();
assert!(registry.available_package_managers().is_empty());
assert!(registry.available_system_configurators().is_empty());
assert!(registry.file_manager.is_none());
assert!(registry.secret_backend.is_none());
}
#[test]
fn test_default_installed_packages_with_versions_empty() {
let mock = StubPackageManager::new("mock");
let pkgs = mock.installed_packages_with_versions().unwrap();
assert!(pkgs.is_empty());
}
#[test]
fn test_default_package_aliases_empty() {
let mock = StubPackageManager::new("mock");
let aliases = mock.package_aliases("fd").unwrap();
assert!(aliases.is_empty());
}
#[test]
fn parse_secret_reference_1password() {
let (provider, rest) = parse_secret_reference("1password://Vault/Item/Field").unwrap();
assert_eq!(provider, "1password");
assert_eq!(rest, "Vault/Item/Field");
}
#[test]
fn parse_secret_reference_bitwarden() {
let (provider, rest) = parse_secret_reference("bitwarden://folder/item").unwrap();
assert_eq!(provider, "bitwarden");
assert_eq!(rest, "folder/item");
}
#[test]
fn parse_secret_reference_lastpass() {
let (provider, rest) = parse_secret_reference("lastpass://folder/item/field").unwrap();
assert_eq!(provider, "lastpass");
assert_eq!(rest, "folder/item/field");
}
#[test]
fn parse_secret_reference_vault() {
let (provider, rest) = parse_secret_reference("vault://secret/path#field").unwrap();
assert_eq!(provider, "vault");
assert_eq!(rest, "secret/path#field");
}
#[test]
fn parse_secret_reference_unknown_returns_none() {
assert!(parse_secret_reference("plaintext").is_none());
assert!(parse_secret_reference("file:///etc/passwd").is_none());
assert!(parse_secret_reference("").is_none());
}
#[test]
fn provider_registry_default_matches_new() {
let reg = ProviderRegistry::default();
assert!(reg.package_managers.is_empty());
assert!(reg.system_configurators.is_empty());
assert!(reg.file_manager.is_none());
assert!(reg.secret_backend.is_none());
assert!(reg.secret_providers.is_empty());
}
struct StubConfigurator {
name: String,
available: bool,
}
impl SystemConfigurator for StubConfigurator {
fn name(&self) -> &str {
&self.name
}
fn is_available(&self) -> bool {
self.available
}
fn current_state(&self) -> Result<serde_yaml::Value> {
Ok(serde_yaml::Value::Null)
}
fn diff(&self, _desired: &serde_yaml::Value) -> Result<Vec<SystemDrift>> {
Ok(Vec::new())
}
fn apply(&self, _desired: &serde_yaml::Value, _printer: &Printer) -> Result<()> {
Ok(())
}
}
#[test]
fn available_system_configurators_filters_unavailable() {
let mut reg = ProviderRegistry::new();
reg.system_configurators.push(Box::new(StubConfigurator {
name: "shell".to_string(),
available: true,
}));
reg.system_configurators.push(Box::new(StubConfigurator {
name: "systemd".to_string(),
available: false,
}));
let available = reg.available_system_configurators();
assert_eq!(available.len(), 1);
assert_eq!(available[0].name(), "shell");
}
#[test]
fn stub_builder_chain_full() {
let stub = StubPackageManager::new("brew")
.bootstrappable()
.with_installed(&["jq", "ripgrep"])
.with_package("jq", "1.7.1");
assert!(stub.is_available());
assert!(stub.can_bootstrap());
assert_eq!(stub.installed_packages().unwrap().len(), 2);
assert_eq!(
stub.available_version("jq").unwrap(),
Some("1.7.1".to_string())
);
assert!(stub.available_version("missing").unwrap().is_none());
}
#[test]
fn stub_with_installed_error_returns_err() {
let stub =
StubPackageManager::new("brew").with_installed_error("simulated brew list failure");
let err = stub.installed_packages().unwrap_err();
let msg = format!("{err}");
assert!(
msg.contains("simulated brew list failure"),
"unexpected error message: {msg}"
);
}
#[test]
fn stub_default_installed_packages_with_versions_with_content() {
let stub = StubPackageManager::new("brew").with_installed(&["fd", "jq"]);
let mut pkgs = stub.installed_packages_with_versions().unwrap();
pkgs.sort_by(|a, b| a.name.cmp(&b.name));
assert_eq!(pkgs.len(), 2);
assert_eq!(pkgs[0].name, "fd");
assert_eq!(pkgs[0].version, "unknown");
assert_eq!(pkgs[1].name, "jq");
assert_eq!(pkgs[1].version, "unknown");
}
#[test]
fn stub_default_path_dirs_empty() {
let stub = StubPackageManager::new("apt");
assert!(stub.path_dirs().is_empty());
}
#[test]
fn package_info_serde_round_trips() {
let info = PackageInfo {
name: "jq".to_string(),
version: "1.7.1".to_string(),
};
let json = serde_json::to_string(&info).unwrap();
assert!(json.contains("\"name\":\"jq\""));
assert!(json.contains("\"version\":\"1.7.1\""));
let parsed: PackageInfo = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.name, info.name);
assert_eq!(parsed.version, info.version);
}
}