Skip to main content

cfgd_core/config/
profile_spec.rs

1use std::collections::HashMap;
2use std::path::PathBuf;
3
4use serde::{Deserialize, Serialize};
5
6use super::module::ScriptEntry;
7use super::source::{EnvVar, ShellAlias};
8use crate::errors::{ConfigError, Result};
9// --- Profile ---
10
11#[derive(Debug, Clone, Serialize, Deserialize)]
12#[serde(rename_all = "camelCase", deny_unknown_fields)]
13pub struct ProfileDocument {
14    pub api_version: String,
15    pub kind: String,
16    pub metadata: ProfileMetadata,
17    pub spec: ProfileSpec,
18}
19
20#[derive(Debug, Clone, Serialize, Deserialize)]
21#[serde(rename_all = "camelCase", deny_unknown_fields)]
22pub struct ProfileMetadata {
23    pub name: String,
24}
25
26#[derive(Debug, Clone, Default, Serialize, Deserialize)]
27#[serde(rename_all = "camelCase", deny_unknown_fields)]
28pub struct ProfileSpec {
29    #[serde(default)]
30    pub inherits: Vec<String>,
31
32    #[serde(default)]
33    pub modules: Vec<String>,
34
35    #[serde(default)]
36    pub env: Vec<EnvVar>,
37
38    #[serde(default)]
39    pub aliases: Vec<ShellAlias>,
40
41    #[serde(default)]
42    pub packages: Option<PackagesSpec>,
43
44    #[serde(default)]
45    pub files: Option<FilesSpec>,
46
47    #[serde(default)]
48    pub system: HashMap<String, serde_yaml::Value>,
49
50    #[serde(default)]
51    pub secrets: Vec<SecretSpec>,
52
53    #[serde(default)]
54    pub scripts: Option<ScriptSpec>,
55}
56
57#[derive(Debug, Clone, Default, Serialize, Deserialize)]
58#[serde(rename_all = "camelCase", deny_unknown_fields)]
59pub struct PackagesSpec {
60    #[serde(default)]
61    pub brew: Option<BrewSpec>,
62    #[serde(default)]
63    pub apt: Option<AptSpec>,
64    #[serde(default)]
65    pub cargo: Option<CargoSpec>,
66    #[serde(default)]
67    pub npm: Option<NpmSpec>,
68    #[serde(default)]
69    pub pipx: Vec<String>,
70    #[serde(default)]
71    pub dnf: Vec<String>,
72    #[serde(default)]
73    pub apk: Vec<String>,
74    #[serde(default)]
75    pub pacman: Vec<String>,
76    #[serde(default)]
77    pub zypper: Vec<String>,
78    #[serde(default)]
79    pub yum: Vec<String>,
80    #[serde(default)]
81    pub pkg: Vec<String>,
82    #[serde(default)]
83    pub snap: Option<SnapSpec>,
84    #[serde(default)]
85    pub flatpak: Option<FlatpakSpec>,
86    #[serde(default)]
87    pub nix: Vec<String>,
88    #[serde(default)]
89    pub go: Vec<String>,
90    #[serde(default)]
91    pub winget: Vec<String>,
92    #[serde(default)]
93    pub chocolatey: Vec<String>,
94    #[serde(default)]
95    pub scoop: Vec<String>,
96    #[serde(default)]
97    pub custom: Vec<CustomManagerSpec>,
98}
99
100impl PackagesSpec {
101    /// Return a mutable reference to the package list for a simple `Vec<String>` manager.
102    /// Returns `None` for managers that use struct wrappers (brew, apt, cargo, npm, snap, flatpak)
103    /// or for unknown manager names.
104    pub fn simple_list_mut(&mut self, manager: &str) -> Option<&mut Vec<String>> {
105        match manager {
106            "pipx" => Some(&mut self.pipx),
107            "dnf" => Some(&mut self.dnf),
108            "apk" => Some(&mut self.apk),
109            "pacman" => Some(&mut self.pacman),
110            "zypper" => Some(&mut self.zypper),
111            "yum" => Some(&mut self.yum),
112            "pkg" => Some(&mut self.pkg),
113            "nix" => Some(&mut self.nix),
114            "go" => Some(&mut self.go),
115            "winget" => Some(&mut self.winget),
116            "chocolatey" => Some(&mut self.chocolatey),
117            "scoop" => Some(&mut self.scoop),
118            _ => None,
119        }
120    }
121
122    /// Return a reference to the package list for a simple `Vec<String>` manager.
123    /// Returns `None` for struct-wrapper managers or unknown names.
124    pub fn simple_list(&self, manager: &str) -> Option<&[String]> {
125        match manager {
126            "pipx" => Some(&self.pipx),
127            "dnf" => Some(&self.dnf),
128            "apk" => Some(&self.apk),
129            "pacman" => Some(&self.pacman),
130            "zypper" => Some(&self.zypper),
131            "yum" => Some(&self.yum),
132            "pkg" => Some(&self.pkg),
133            "nix" => Some(&self.nix),
134            "go" => Some(&self.go),
135            "winget" => Some(&self.winget),
136            "chocolatey" => Some(&self.chocolatey),
137            "scoop" => Some(&self.scoop),
138            _ => None,
139        }
140    }
141
142    /// Return all non-empty simple-list managers as `(name, packages)` pairs.
143    pub fn non_empty_simple_lists(&self) -> Vec<(&str, &[String])> {
144        let mut result = Vec::new();
145        for name in &[
146            "pipx",
147            "dnf",
148            "apk",
149            "pacman",
150            "zypper",
151            "yum",
152            "pkg",
153            "nix",
154            "go",
155            "winget",
156            "chocolatey",
157            "scoop",
158        ] {
159            if let Some(list) = self.simple_list(name)
160                && !list.is_empty()
161            {
162                result.push((*name, list));
163            }
164        }
165        result
166    }
167}
168
169#[derive(Debug, Clone, Default, Serialize, Deserialize)]
170#[serde(rename_all = "camelCase", deny_unknown_fields)]
171pub struct BrewSpec {
172    #[serde(default)]
173    pub file: Option<String>,
174    #[serde(default)]
175    pub taps: Vec<String>,
176    #[serde(default)]
177    pub formulae: Vec<String>,
178    #[serde(default)]
179    pub casks: Vec<String>,
180}
181
182#[derive(Debug, Clone, Default, Serialize, Deserialize)]
183#[serde(rename_all = "camelCase", deny_unknown_fields)]
184pub struct AptSpec {
185    #[serde(default)]
186    pub file: Option<String>,
187    #[serde(default)]
188    pub packages: Vec<String>,
189}
190
191#[derive(Debug, Clone, Default, Serialize, Deserialize)]
192#[serde(rename_all = "camelCase", deny_unknown_fields)]
193pub struct NpmSpec {
194    #[serde(default)]
195    pub file: Option<String>,
196    #[serde(default)]
197    pub global: Vec<String>,
198}
199
200/// Cargo package spec. Supports both list form (`cargo: [bat, ripgrep]`)
201/// and object form (`cargo: { file: Cargo.toml, packages: [...] }`).
202#[derive(Debug, Clone, Default, PartialEq, Serialize)]
203pub struct CargoSpec {
204    #[serde(default, skip_serializing_if = "Option::is_none")]
205    pub file: Option<String>,
206    #[serde(default)]
207    pub packages: Vec<String>,
208}
209
210impl<'de> Deserialize<'de> for CargoSpec {
211    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
212    where
213        D: serde::Deserializer<'de>,
214    {
215        use serde::de;
216
217        #[derive(Deserialize)]
218        #[serde(rename_all = "camelCase", deny_unknown_fields)]
219        struct CargoSpecFull {
220            #[serde(default)]
221            file: Option<String>,
222            #[serde(default)]
223            packages: Vec<String>,
224        }
225
226        // Try to deserialize as either a list of strings or a map with file/packages
227        struct CargoSpecVisitor;
228
229        impl<'de> de::Visitor<'de> for CargoSpecVisitor {
230            type Value = CargoSpec;
231
232            fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
233                formatter.write_str("a list of package names or a map with file/packages keys")
234            }
235
236            fn visit_seq<A>(self, mut seq: A) -> std::result::Result<CargoSpec, A::Error>
237            where
238                A: de::SeqAccess<'de>,
239            {
240                let mut packages = Vec::new();
241                while let Some(item) = seq.next_element::<String>()? {
242                    packages.push(item);
243                }
244                Ok(CargoSpec {
245                    file: None,
246                    packages,
247                })
248            }
249
250            fn visit_map<M>(self, map: M) -> std::result::Result<CargoSpec, M::Error>
251            where
252                M: de::MapAccess<'de>,
253            {
254                let full = CargoSpecFull::deserialize(de::value::MapAccessDeserializer::new(map))?;
255                Ok(CargoSpec {
256                    file: full.file,
257                    packages: full.packages,
258                })
259            }
260        }
261
262        deserializer.deserialize_any(CargoSpecVisitor)
263    }
264}
265
266#[derive(Debug, Clone, Default, Serialize, Deserialize)]
267#[serde(rename_all = "camelCase", deny_unknown_fields)]
268pub struct SnapSpec {
269    #[serde(default)]
270    pub packages: Vec<String>,
271    #[serde(default)]
272    pub classic: Vec<String>,
273}
274
275#[derive(Debug, Clone, Default, Serialize, Deserialize)]
276#[serde(rename_all = "camelCase", deny_unknown_fields)]
277pub struct FlatpakSpec {
278    #[serde(default)]
279    pub packages: Vec<String>,
280    #[serde(default)]
281    pub remote: Option<String>,
282}
283
284#[derive(Debug, Clone, Serialize, Deserialize)]
285#[serde(rename_all = "camelCase", deny_unknown_fields)]
286pub struct CustomManagerSpec {
287    pub name: String,
288    pub check: String,
289    pub list_installed: String,
290    pub install: String,
291    pub uninstall: String,
292    #[serde(default)]
293    pub update: Option<String>,
294    #[serde(default)]
295    pub packages: Vec<String>,
296}
297
298#[derive(Debug, Clone, Default, Serialize, Deserialize)]
299#[serde(rename_all = "camelCase", deny_unknown_fields)]
300pub struct FilesSpec {
301    #[serde(default)]
302    pub managed: Vec<ManagedFileSpec>,
303    #[serde(default)]
304    pub permissions: HashMap<String, String>,
305}
306
307/// File deployment strategy.
308#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
309pub enum FileStrategy {
310    /// Create a symbolic link from target to source (default).
311    #[default]
312    Symlink,
313    /// Copy source content to target.
314    Copy,
315    /// Render a Tera template and write the output (auto-selected for .tera files).
316    Template,
317    /// Create a hard link from target to source.
318    Hardlink,
319}
320
321/// Controls when encryption is required for a managed file.
322#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
323pub enum EncryptionMode {
324    /// File must be encrypted when stored in the repository.
325    #[default]
326    InRepo,
327    /// File must always be encrypted, including at rest on disk.
328    Always,
329}
330
331/// Encryption settings for a managed file.
332#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
333#[serde(rename_all = "camelCase", deny_unknown_fields)]
334pub struct EncryptionSpec {
335    /// The encryption backend to use (e.g. "sops", "age").
336    pub backend: String,
337    /// When encryption must be enforced. Defaults to `InRepo`.
338    #[serde(default)]
339    pub mode: EncryptionMode,
340}
341
342/// Encryption constraint applied to files from a config source.
343#[derive(Debug, Clone, Default, Serialize, Deserialize)]
344#[serde(rename_all = "camelCase", deny_unknown_fields)]
345pub struct EncryptionConstraint {
346    /// Glob patterns or explicit paths that must be encrypted.
347    #[serde(default)]
348    pub required_targets: Vec<String>,
349    /// If set, restrict which backend is acceptable.
350    #[serde(default, skip_serializing_if = "Option::is_none")]
351    pub backend: Option<String>,
352    /// If set, restrict which encryption mode is acceptable.
353    #[serde(default, skip_serializing_if = "Option::is_none")]
354    pub mode: Option<EncryptionMode>,
355}
356
357#[derive(Debug, Clone, Serialize, Deserialize)]
358#[serde(rename_all = "camelCase", deny_unknown_fields)]
359pub struct ManagedFileSpec {
360    pub source: String,
361    pub target: PathBuf,
362    /// Per-file deployment strategy override. If None, uses the global default.
363    #[serde(default, skip_serializing_if = "Option::is_none")]
364    pub strategy: Option<FileStrategy>,
365    /// When true, the source file is local-only: auto-added to .gitignore,
366    /// silently skipped on machines where it doesn't exist.
367    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
368    pub private: bool,
369    /// Which source this file came from (None = local config).
370    /// Used by the template sandbox to restrict variable access.
371    #[serde(skip)]
372    pub origin: Option<String>,
373    /// Encryption settings for this file.
374    #[serde(default, skip_serializing_if = "Option::is_none")]
375    pub encryption: Option<EncryptionSpec>,
376    /// Unix permission bits (e.g. "600", "644") to apply after deployment.
377    #[serde(default, skip_serializing_if = "Option::is_none")]
378    pub permissions: Option<String>,
379}
380
381#[derive(Debug, Clone, Serialize, Deserialize)]
382#[serde(rename_all = "camelCase", deny_unknown_fields)]
383pub struct SecretSpec {
384    pub source: String,
385    #[serde(default, skip_serializing_if = "Option::is_none")]
386    pub target: Option<PathBuf>,
387    #[serde(default, skip_serializing_if = "Option::is_none")]
388    pub template: Option<String>,
389    #[serde(default, skip_serializing_if = "Option::is_none")]
390    pub backend: Option<String>,
391    #[serde(default, skip_serializing_if = "Option::is_none")]
392    pub envs: Option<Vec<String>>,
393}
394
395/// Validate that each secret has at least one delivery target (`target` or `envs`).
396pub fn validate_secret_specs(specs: &[SecretSpec]) -> Result<()> {
397    for spec in specs {
398        if spec.target.is_none() && spec.envs.as_ref().is_none_or(|e| e.is_empty()) {
399            return Err(ConfigError::Invalid {
400                message: format!(
401                    "secret '{}' must have at least one of 'target' or 'envs'",
402                    spec.source
403                ),
404            }
405            .into());
406        }
407    }
408    Ok(())
409}
410
411#[derive(Debug, Clone, Default, Serialize, Deserialize)]
412#[serde(rename_all = "camelCase", deny_unknown_fields)]
413pub struct ScriptSpec {
414    #[serde(default)]
415    pub pre_apply: Vec<ScriptEntry>,
416    #[serde(default)]
417    pub post_apply: Vec<ScriptEntry>,
418    #[serde(default)]
419    pub pre_reconcile: Vec<ScriptEntry>,
420    #[serde(default)]
421    pub post_reconcile: Vec<ScriptEntry>,
422    #[serde(default)]
423    pub on_drift: Vec<ScriptEntry>,
424    #[serde(default)]
425    pub on_change: Vec<ScriptEntry>,
426}
427
428#[cfg(test)]
429mod tests {
430    use super::*;
431
432    #[test]
433    fn profile_spec_rejects_unknown_field() {
434        let yaml = "modules: []\nbogus: 1\n";
435        let err = serde_yaml::from_str::<ProfileSpec>(yaml)
436            .expect_err("expected deny_unknown_fields to reject bogus");
437        assert!(format!("{}", err).contains("unknown field"));
438    }
439
440    #[test]
441    fn packages_spec_rejects_typo_for_known_manager() {
442        // `brwe:` typo (meant `brew:`) must error loudly, not silently drop.
443        let yaml = "brwe:\n  formulae: [ripgrep]\n";
444        let err = serde_yaml::from_str::<PackagesSpec>(yaml)
445            .expect_err("expected deny_unknown_fields to reject brwe typo");
446        let msg = format!("{}", err);
447        assert!(
448            msg.contains("unknown field") && msg.contains("brwe"),
449            "expected unknown-field error mentioning brwe, got: {msg}"
450        );
451    }
452
453    #[test]
454    fn managed_file_spec_rejects_unknown_field() {
455        let yaml = "source: a\ntarget: /tmp/b\nbogus: 1\n";
456        let err = serde_yaml::from_str::<ManagedFileSpec>(yaml)
457            .expect_err("expected deny_unknown_fields to reject bogus");
458        assert!(format!("{}", err).contains("unknown field"));
459    }
460}