Skip to main content

cfgd_core/config/
root.rs

1use std::collections::HashMap;
2use std::path::Path;
3
4use serde::{Deserialize, Serialize};
5
6use super::ai::AiConfig;
7use super::compliance::ComplianceConfig;
8use super::daemon::DaemonConfig;
9use super::origin::OriginSpec;
10use super::profile_spec::FileStrategy;
11use super::security::{ModulesConfig, SecurityConfig};
12use super::source::SourceSpec;
13use super::sync_secrets::SecretsConfig;
14use super::theme::ThemeConfig;
15use crate::errors::Result;
16
17// --- Root Config (cfgd.yaml) ---
18
19#[derive(Debug, Clone, Serialize, Deserialize)]
20#[serde(rename_all = "camelCase", deny_unknown_fields)]
21pub struct CfgdConfig {
22    pub api_version: String,
23    pub kind: String,
24    pub metadata: ConfigMetadata,
25    pub spec: ConfigSpec,
26}
27
28impl CfgdConfig {
29    /// Returns the active profile name, or an error if no profile is configured.
30    pub fn active_profile(&self) -> Result<&str> {
31        self.spec
32            .profile
33            .as_deref()
34            .filter(|p| !p.is_empty())
35            .ok_or_else(|| {
36                crate::errors::CfgdError::Config(crate::errors::ConfigError::Invalid {
37                    message: "no profile configured — run: cfgd profile create <name>".to_string(),
38                })
39            })
40    }
41}
42
43#[derive(Debug, Clone, Serialize, Deserialize)]
44#[serde(rename_all = "camelCase", deny_unknown_fields)]
45pub struct ConfigMetadata {
46    pub name: String,
47}
48
49#[derive(Debug, Clone, Default, Serialize, Deserialize)]
50#[serde(rename_all = "camelCase", deny_unknown_fields)]
51pub struct ConfigSpec {
52    #[serde(default)]
53    pub profile: Option<String>,
54
55    #[serde(default)]
56    pub origin: Vec<OriginSpec>,
57
58    #[serde(default)]
59    pub daemon: Option<DaemonConfig>,
60
61    #[serde(default)]
62    pub secrets: Option<SecretsConfig>,
63
64    #[serde(default)]
65    pub sources: Vec<SourceSpec>,
66
67    #[serde(default)]
68    pub theme: Option<ThemeConfig>,
69
70    /// Module configuration: registries and security.
71    #[serde(default)]
72    pub modules: Option<ModulesConfig>,
73
74    /// Global default file deployment strategy. Per-file overrides take precedence.
75    #[serde(default)]
76    pub file_strategy: FileStrategy,
77
78    /// Security settings for source signature verification.
79    #[serde(default)]
80    pub security: Option<SecurityConfig>,
81
82    /// CLI aliases: map of alias name → command string.
83    /// Built-in defaults (add, remove) can be overridden or extended.
84    #[serde(default)]
85    pub aliases: HashMap<String, String>,
86
87    /// AI assistant configuration: provider, model, and API key env var.
88    #[serde(default)]
89    pub ai: Option<AiConfig>,
90
91    /// Compliance snapshot configuration.
92    #[serde(default)]
93    pub compliance: Option<ComplianceConfig>,
94}
95
96/// Returns `true` if `path` has a YAML extension (`.yaml` or `.yml`,
97/// case-sensitive to match the rest of cfgd).
98///
99/// Use this instead of inlining `ext == "yaml" || ext == "yml"` checks when
100/// iterating module / profile directories — keeps the "what counts as a YAML
101/// file" decision in one place.
102pub fn is_yaml_ext(path: &Path) -> bool {
103    path.extension().is_some_and(|e| e == "yaml" || e == "yml")
104}
105
106/// Iterate over every `.yaml` / `.yml` file in `dir`, invoking `f(path)` for each.
107///
108/// - Non-existent `dir` is **not** an error — yields nothing.
109/// - Non-YAML entries, subdirectories, and unreadable entries are silently skipped.
110/// - `f`'s error short-circuits the walk (first error wins).
111///
112/// Use this instead of open-coding `std::fs::read_dir` + `is_yaml_ext` checks
113/// when scanning `<config_dir>/profiles` / `<config_dir>/modules` trees.
114pub fn for_each_yaml_file<F>(dir: &Path, mut f: F) -> std::io::Result<()>
115where
116    F: FnMut(&Path) -> std::io::Result<()>,
117{
118    if !dir.exists() {
119        return Ok(());
120    }
121    for entry in std::fs::read_dir(dir)? {
122        let entry = match entry {
123            Ok(e) => e,
124            Err(_) => continue,
125        };
126        let path = entry.path();
127        if is_yaml_ext(&path) {
128            f(&path)?;
129        }
130    }
131    Ok(())
132}
133
134/// Build a minimal CfgdConfig for module-only operations that don't have cfgd.yaml.
135pub fn minimal_config() -> CfgdConfig {
136    CfgdConfig {
137        api_version: crate::API_VERSION.to_string(),
138        kind: "Config".to_string(),
139        metadata: ConfigMetadata {
140            name: "default".to_string(),
141        },
142        spec: ConfigSpec::default(),
143    }
144}
145
146// Custom deserialization: origin can be a single object or an array
147// Internally always Vec<OriginSpec> with primary at index 0
148impl ConfigSpec {
149    pub fn primary_origin(&self) -> Option<&OriginSpec> {
150        self.origin.first()
151    }
152}
153
154#[cfg(test)]
155mod tests {
156    use super::*;
157
158    #[test]
159    fn minimal_config_has_correct_shape() {
160        let c = minimal_config();
161        assert_eq!(c.api_version, crate::API_VERSION);
162        assert_eq!(c.kind, "Config");
163        assert_eq!(c.metadata.name, "default");
164        assert!(c.spec.profile.is_none());
165        assert!(c.spec.origin.is_empty());
166    }
167
168    #[test]
169    fn active_profile_returns_error_when_none() {
170        let c = minimal_config();
171        assert!(c.active_profile().is_err());
172    }
173
174    #[test]
175    fn active_profile_returns_error_when_empty_string() {
176        let mut c = minimal_config();
177        c.spec.profile = Some(String::new());
178        assert!(c.active_profile().is_err());
179    }
180
181    #[test]
182    fn active_profile_returns_name_when_set() {
183        let mut c = minimal_config();
184        c.spec.profile = Some("work".to_string());
185        assert_eq!(c.active_profile().unwrap(), "work");
186    }
187
188    #[test]
189    fn primary_origin_none_when_empty() {
190        let spec = ConfigSpec::default();
191        assert!(spec.primary_origin().is_none());
192    }
193
194    #[test]
195    fn primary_origin_returns_first() {
196        let mut spec = ConfigSpec::default();
197        spec.origin.push(OriginSpec {
198            origin_type: crate::config::OriginType::Git,
199            url: "https://example.com/dotfiles.git".to_string(),
200            branch: "main".to_string(),
201            auth: None,
202            ssh_strict_host_key_checking: Default::default(),
203        });
204        assert_eq!(
205            spec.primary_origin().unwrap().url,
206            "https://example.com/dotfiles.git"
207        );
208    }
209
210    #[test]
211    fn is_yaml_ext_accepts_yaml_and_yml() {
212        assert!(is_yaml_ext(Path::new("foo.yaml")));
213        assert!(is_yaml_ext(Path::new("bar.yml")));
214        assert!(!is_yaml_ext(Path::new("baz.toml")));
215        assert!(!is_yaml_ext(Path::new("noext")));
216    }
217
218    #[test]
219    fn for_each_yaml_file_nonexistent_dir_is_ok() {
220        let r = for_each_yaml_file(Path::new("/nonexistent/path/xyz"), |_| Ok(()));
221        assert!(r.is_ok());
222    }
223
224    #[test]
225    fn cfgd_config_rejects_unknown_top_level_fields() {
226        let yaml = "apiVersion: cfgd.io/v1alpha1\nkind: Config\nbogusField: nope\nmetadata:\n  name: t\nspec: {}\n";
227        let err = serde_yaml::from_str::<CfgdConfig>(yaml)
228            .expect_err("expected deny_unknown_fields to reject bogusField");
229        let msg = format!("{}", err);
230        assert!(
231            msg.contains("unknown field"),
232            "expected unknown-field error, got: {msg}"
233        );
234    }
235
236    #[test]
237    fn config_spec_rejects_unknown_field_typo() {
238        // Real-world scenario: a typo at the spec level should be caught (e.g.
239        // `securty:` instead of `security:`). Surfaces drift-style typos.
240        let yaml = "profile: default\nsecurty: {}\n";
241        let err = serde_yaml::from_str::<ConfigSpec>(yaml)
242            .expect_err("expected deny_unknown_fields to reject securty typo");
243        let msg = format!("{}", err);
244        assert!(
245            msg.contains("unknown field") && msg.contains("securty"),
246            "expected unknown-field error mentioning securty, got: {msg}"
247        );
248    }
249
250    #[test]
251    fn for_each_yaml_file_visits_yaml_files() {
252        let dir = tempfile::tempdir().unwrap();
253        std::fs::write(dir.path().join("a.yaml"), "").unwrap();
254        std::fs::write(dir.path().join("b.yml"), "").unwrap();
255        std::fs::write(dir.path().join("c.toml"), "").unwrap();
256        let mut visited = Vec::new();
257        for_each_yaml_file(dir.path(), |p| {
258            visited.push(p.file_name().unwrap().to_string_lossy().to_string());
259            Ok(())
260        })
261        .unwrap();
262        visited.sort();
263        assert_eq!(visited, vec!["a.yaml", "b.yml"]);
264    }
265}