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#[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 #[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 #[serde(default, skip_serializing_if = "Option::is_none")]
89 pub strategy: Option<FileStrategy>,
90 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
93 pub private: bool,
94 #[serde(default, skip_serializing_if = "Option::is_none")]
96 pub encryption: Option<EncryptionSpec>,
97}
98
99#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
101#[serde(rename_all = "camelCase")]
102pub enum ScriptShell {
103 #[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 #[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 #[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 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#[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#[derive(Debug, Clone, Serialize, Deserialize)]
175#[serde(rename_all = "camelCase", deny_unknown_fields)]
176pub struct ModuleLockEntry {
177 pub name: String,
179 pub url: String,
181 pub pinned_ref: String,
183 pub commit: String,
185 pub integrity: String,
187 #[serde(default, skip_serializing_if = "Option::is_none")]
189 pub subdir: Option<String>,
190}
191
192#[derive(Debug, Clone, Serialize, Deserialize)]
196#[serde(rename_all = "camelCase", deny_unknown_fields)]
197pub struct ModuleRegistryEntry {
198 pub name: String,
200 pub url: String,
202}
203
204pub 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}