Skip to main content

cfgd_core/config/
module.rs

1use std::collections::HashMap;
2use std::path::Path;
3
4use serde::{Deserialize, Serialize};
5
6use super::parse::check_yaml_anchor_limit;
7use super::profile_spec::{EncryptionSpec, FileStrategy, ScriptSpec};
8use super::source::{EnvVar, ShellAlias};
9use crate::errors::{ConfigError, Result};
10
11// --- Module ---
12
13#[derive(Debug, Clone, Serialize, Deserialize)]
14#[serde(rename_all = "camelCase", deny_unknown_fields)]
15pub struct ModuleDocument {
16    pub api_version: String,
17    pub kind: String,
18    pub metadata: ModuleMetadata,
19    pub spec: ModuleSpec,
20}
21
22#[derive(Debug, Clone, Serialize, Deserialize)]
23#[serde(rename_all = "camelCase", deny_unknown_fields)]
24pub struct ModuleMetadata {
25    pub name: String,
26    #[serde(default)]
27    pub description: Option<String>,
28}
29
30#[derive(Debug, Clone, Default, Serialize, Deserialize)]
31#[serde(rename_all = "camelCase", deny_unknown_fields)]
32pub struct ModuleSpec {
33    #[serde(default, skip_serializing_if = "Vec::is_empty")]
34    pub depends: Vec<String>,
35
36    #[serde(default, skip_serializing_if = "Vec::is_empty")]
37    pub packages: Vec<ModulePackageEntry>,
38
39    #[serde(default, skip_serializing_if = "Vec::is_empty")]
40    pub files: Vec<ModuleFileEntry>,
41
42    #[serde(default, skip_serializing_if = "Vec::is_empty")]
43    pub env: Vec<EnvVar>,
44
45    #[serde(default, skip_serializing_if = "Vec::is_empty")]
46    pub aliases: Vec<ShellAlias>,
47
48    #[serde(default, skip_serializing_if = "Option::is_none")]
49    pub scripts: Option<ScriptSpec>,
50
51    /// System configurator settings contributed by this module.
52    /// Deep-merged into the profile system map; module values override profile values at leaf level.
53    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
54    pub system: HashMap<String, serde_yaml::Value>,
55}
56
57#[derive(Debug, Clone, Default, Serialize, Deserialize)]
58#[serde(rename_all = "camelCase", deny_unknown_fields)]
59pub struct ModulePackageEntry {
60    #[serde(default)]
61    pub name: String,
62
63    #[serde(default, skip_serializing_if = "Option::is_none")]
64    pub min_version: Option<String>,
65
66    #[serde(default, skip_serializing_if = "Vec::is_empty")]
67    pub prefer: Vec<String>,
68
69    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
70    pub aliases: HashMap<String, String>,
71
72    #[serde(default, skip_serializing_if = "Option::is_none")]
73    pub script: Option<String>,
74
75    #[serde(default, skip_serializing_if = "Vec::is_empty")]
76    pub deny: Vec<String>,
77
78    #[serde(default, skip_serializing_if = "Vec::is_empty")]
79    pub platforms: Vec<String>,
80}
81
82#[derive(Debug, Clone, Serialize, Deserialize)]
83#[serde(rename_all = "camelCase", deny_unknown_fields)]
84pub struct ModuleFileEntry {
85    pub source: String,
86    pub target: String,
87    /// Per-file deployment strategy override. If None, uses the global default.
88    #[serde(default, skip_serializing_if = "Option::is_none")]
89    pub strategy: Option<FileStrategy>,
90    /// When true, the source file is local-only: auto-added to .gitignore,
91    /// silently skipped on machines where it doesn't exist.
92    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
93    pub private: bool,
94    /// Encryption settings for this module file.
95    #[serde(default, skip_serializing_if = "Option::is_none")]
96    pub encryption: Option<EncryptionSpec>,
97}
98
99/// Interpreter for inline lifecycle scripts.
100#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
101#[serde(rename_all = "camelCase")]
102pub enum ScriptShell {
103    /// Platform default: `sh` on Unix, `cmd.exe` on Windows.
104    #[default]
105    Auto,
106    Sh,
107    Bash,
108    Zsh,
109    Pwsh,
110    Cmd,
111}
112
113#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
114#[serde(untagged)]
115pub enum ScriptEntry {
116    Simple(String),
117    Full {
118        run: String,
119        #[serde(default, skip_serializing_if = "Option::is_none")]
120        timeout: Option<String>,
121        /// Kill the script if it produces no stdout/stderr output for this duration.
122        /// Prevents scripts from silently hanging on unresponsive resources.
123        /// Format: "30s", "2m", etc. If unset, no idle timeout is enforced.
124        #[serde(
125            default,
126            skip_serializing_if = "Option::is_none",
127            rename = "idleTimeout"
128        )]
129        idle_timeout: Option<String>,
130        #[serde(
131            default,
132            skip_serializing_if = "Option::is_none",
133            rename = "continueOnError"
134        )]
135        continue_on_error: Option<bool>,
136        /// Interpreter to use for inline commands. Ignored (and rejected) on file scripts.
137        #[serde(default, skip_serializing_if = "is_shell_auto")]
138        shell: ScriptShell,
139    },
140}
141
142fn is_shell_auto(s: &ScriptShell) -> bool {
143    *s == ScriptShell::Auto
144}
145
146impl ScriptEntry {
147    /// Extract the run command string from any variant.
148    pub fn run_str(&self) -> &str {
149        match self {
150            ScriptEntry::Simple(s) => s,
151            ScriptEntry::Full { run, .. } => run,
152        }
153    }
154}
155
156impl std::fmt::Display for ScriptEntry {
157    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
158        f.write_str(self.run_str())
159    }
160}
161
162// --- Module Lockfile ---
163
164/// Lockfile recording pinned remote modules with integrity hashes.
165/// Stored at `<config_dir>/modules.lock`.
166#[derive(Debug, Clone, Default, Serialize, Deserialize)]
167#[serde(rename_all = "camelCase", deny_unknown_fields)]
168pub struct ModuleLockfile {
169    #[serde(default)]
170    pub modules: Vec<ModuleLockEntry>,
171}
172
173/// A single locked remote module.
174#[derive(Debug, Clone, Serialize, Deserialize)]
175#[serde(rename_all = "camelCase", deny_unknown_fields)]
176pub struct ModuleLockEntry {
177    /// Module name (matches metadata.name in the module spec).
178    pub name: String,
179    /// Git URL of the remote module repository.
180    pub url: String,
181    /// Pinned git ref — tag or commit SHA (branches not allowed for remote modules).
182    pub pinned_ref: String,
183    /// Resolved commit SHA at the time of locking.
184    pub commit: String,
185    /// SHA-256 hash of the module directory contents for integrity verification.
186    pub integrity: String,
187    /// Subdirectory within the repo containing the module.
188    #[serde(default, skip_serializing_if = "Option::is_none")]
189    pub subdir: Option<String>,
190}
191
192// --- Module Registries ---
193
194/// A module registry — a git repo containing modules in `modules/<name>/module.yaml` structure.
195#[derive(Debug, Clone, Serialize, Deserialize)]
196#[serde(rename_all = "camelCase", deny_unknown_fields)]
197pub struct ModuleRegistryEntry {
198    /// Short name / alias for this source (defaults to GitHub org name).
199    pub name: String,
200    /// Git URL of the source repository.
201    pub url: String,
202}
203
204/// Parse a Module document from YAML content.
205pub fn parse_module(contents: &str) -> Result<ModuleDocument> {
206    check_yaml_anchor_limit(contents, Path::new("Module"))?;
207    let doc: ModuleDocument = serde_yaml::from_str(contents).map_err(ConfigError::from)?;
208
209    if doc.kind != "Module" {
210        return Err(ConfigError::Invalid {
211            message: format!("expected kind 'Module', got '{}'", doc.kind),
212        }
213        .into());
214    }
215
216    Ok(doc)
217}
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222
223    #[test]
224    fn module_spec_rejects_unknown_field() {
225        let yaml = "depends: []\nbogus: 1\n";
226        let err = serde_yaml::from_str::<ModuleSpec>(yaml)
227            .expect_err("expected deny_unknown_fields to reject bogus");
228        assert!(format!("{}", err).contains("unknown field"));
229    }
230
231    #[test]
232    fn module_document_rejects_unknown_top_level_field() {
233        let yaml = r#"apiVersion: cfgd.io/v1alpha1
234kind: Module
235bogusField: nope
236metadata:
237  name: m
238spec: {}
239"#;
240        let err = serde_yaml::from_str::<ModuleDocument>(yaml)
241            .expect_err("expected deny_unknown_fields to reject bogusField");
242        let msg = format!("{}", err);
243        assert!(
244            msg.contains("unknown field") && msg.contains("bogusField"),
245            "expected unknown-field error mentioning bogusField, got: {msg}"
246        );
247    }
248
249    #[test]
250    fn script_entry_full_deserializes_shell_field() {
251        let yaml = r#"
252run: echo hello
253shell: zsh
254"#;
255        let entry: ScriptEntry = serde_yaml::from_str(yaml).unwrap();
256        match entry {
257            ScriptEntry::Full { shell, run, .. } => {
258                assert_eq!(shell, ScriptShell::Zsh);
259                assert_eq!(run, "echo hello");
260            }
261            other => panic!("expected Full variant, got: {other:?}"),
262        }
263    }
264
265    #[test]
266    fn script_entry_full_shell_defaults_to_auto() {
267        let yaml = r#"
268run: echo hello
269"#;
270        let entry: ScriptEntry = serde_yaml::from_str(yaml).unwrap();
271        match entry {
272            ScriptEntry::Full { shell, .. } => {
273                assert_eq!(shell, ScriptShell::Auto);
274            }
275            other => panic!("expected Full variant, got: {other:?}"),
276        }
277    }
278
279    #[test]
280    fn script_entry_unknown_shell_variant_rejected() {
281        let yaml = r#"
282run: echo hello
283shell: ruby
284"#;
285        let err = serde_yaml::from_str::<ScriptEntry>(yaml)
286            .expect_err("unknown shell variant must be rejected");
287        let msg = format!("{err}");
288        assert!(
289            msg.contains("did not match any variant"),
290            "error should indicate parse failure: {msg}"
291        );
292    }
293
294    #[test]
295    fn script_shell_roundtrip_serialization() {
296        let entry = ScriptEntry::Full {
297            run: "make build".into(),
298            timeout: None,
299            idle_timeout: None,
300            continue_on_error: None,
301            shell: ScriptShell::Bash,
302        };
303        let yaml = serde_yaml::to_string(&entry).unwrap();
304        assert!(
305            yaml.contains("shell: bash"),
306            "yaml should contain 'shell: bash': {yaml}"
307        );
308
309        let roundtripped: ScriptEntry = serde_yaml::from_str(&yaml).unwrap();
310        assert_eq!(entry, roundtripped);
311    }
312
313    #[test]
314    fn script_shell_auto_not_serialized() {
315        let entry = ScriptEntry::Full {
316            run: "echo hi".into(),
317            timeout: None,
318            idle_timeout: None,
319            continue_on_error: None,
320            shell: ScriptShell::Auto,
321        };
322        let yaml = serde_yaml::to_string(&entry).unwrap();
323        assert!(
324            !yaml.contains("shell"),
325            "Auto shell should be skipped in serialization: {yaml}"
326        );
327    }
328}