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#[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 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 #[serde(default)]
72 pub modules: Option<ModulesConfig>,
73
74 #[serde(default)]
76 pub file_strategy: FileStrategy,
77
78 #[serde(default)]
80 pub security: Option<SecurityConfig>,
81
82 #[serde(default)]
85 pub aliases: HashMap<String, String>,
86
87 #[serde(default)]
89 pub ai: Option<AiConfig>,
90
91 #[serde(default)]
93 pub compliance: Option<ComplianceConfig>,
94}
95
96pub fn is_yaml_ext(path: &Path) -> bool {
103 path.extension().is_some_and(|e| e == "yaml" || e == "yml")
104}
105
106pub 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
134pub 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
146impl 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 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}