use std::collections::HashMap;
use std::path::Path;
use serde::{Deserialize, Serialize};
use super::parse::check_yaml_anchor_limit;
use super::profile_spec::{EncryptionSpec, FileStrategy, ScriptSpec};
use super::source::{EnvVar, ShellAlias};
use crate::errors::{ConfigError, Result};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct ModuleDocument {
pub api_version: String,
pub kind: String,
pub metadata: ModuleMetadata,
pub spec: ModuleSpec,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct ModuleMetadata {
pub name: String,
#[serde(default)]
pub description: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct ModuleSpec {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub depends: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub packages: Vec<ModulePackageEntry>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub files: Vec<ModuleFileEntry>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub env: Vec<EnvVar>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub aliases: Vec<ShellAlias>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub scripts: Option<ScriptSpec>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub system: HashMap<String, serde_yaml::Value>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct ModulePackageEntry {
#[serde(default)]
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub min_version: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub prefer: Vec<String>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub aliases: HashMap<String, String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub script: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub deny: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub platforms: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct ModuleFileEntry {
pub source: String,
pub target: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub strategy: Option<FileStrategy>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub private: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub encryption: Option<EncryptionSpec>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub enum ScriptShell {
#[default]
Auto,
Sh,
Bash,
Zsh,
Pwsh,
Cmd,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum ScriptEntry {
Simple(String),
Full {
run: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
timeout: Option<String>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
rename = "idleTimeout"
)]
idle_timeout: Option<String>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
rename = "continueOnError"
)]
continue_on_error: Option<bool>,
#[serde(default, skip_serializing_if = "is_shell_auto")]
shell: ScriptShell,
},
}
fn is_shell_auto(s: &ScriptShell) -> bool {
*s == ScriptShell::Auto
}
impl ScriptEntry {
pub fn run_str(&self) -> &str {
match self {
ScriptEntry::Simple(s) => s,
ScriptEntry::Full { run, .. } => run,
}
}
}
impl std::fmt::Display for ScriptEntry {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.run_str())
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct ModuleLockfile {
#[serde(default)]
pub modules: Vec<ModuleLockEntry>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct ModuleLockEntry {
pub name: String,
pub url: String,
pub pinned_ref: String,
pub commit: String,
pub integrity: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub subdir: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct ModuleRegistryEntry {
pub name: String,
pub url: String,
}
pub fn parse_module(contents: &str) -> Result<ModuleDocument> {
check_yaml_anchor_limit(contents, Path::new("Module"))?;
let doc: ModuleDocument = serde_yaml::from_str(contents).map_err(ConfigError::from)?;
if doc.kind != "Module" {
return Err(ConfigError::Invalid {
message: format!("expected kind 'Module', got '{}'", doc.kind),
}
.into());
}
Ok(doc)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn module_spec_rejects_unknown_field() {
let yaml = "depends: []\nbogus: 1\n";
let err = serde_yaml::from_str::<ModuleSpec>(yaml)
.expect_err("expected deny_unknown_fields to reject bogus");
assert!(format!("{}", err).contains("unknown field"));
}
#[test]
fn module_document_rejects_unknown_top_level_field() {
let yaml = r#"apiVersion: cfgd.io/v1alpha1
kind: Module
bogusField: nope
metadata:
name: m
spec: {}
"#;
let err = serde_yaml::from_str::<ModuleDocument>(yaml)
.expect_err("expected deny_unknown_fields to reject bogusField");
let msg = format!("{}", err);
assert!(
msg.contains("unknown field") && msg.contains("bogusField"),
"expected unknown-field error mentioning bogusField, got: {msg}"
);
}
#[test]
fn script_entry_full_deserializes_shell_field() {
let yaml = r#"
run: echo hello
shell: zsh
"#;
let entry: ScriptEntry = serde_yaml::from_str(yaml).unwrap();
match entry {
ScriptEntry::Full { shell, run, .. } => {
assert_eq!(shell, ScriptShell::Zsh);
assert_eq!(run, "echo hello");
}
other => panic!("expected Full variant, got: {other:?}"),
}
}
#[test]
fn script_entry_full_shell_defaults_to_auto() {
let yaml = r#"
run: echo hello
"#;
let entry: ScriptEntry = serde_yaml::from_str(yaml).unwrap();
match entry {
ScriptEntry::Full { shell, .. } => {
assert_eq!(shell, ScriptShell::Auto);
}
other => panic!("expected Full variant, got: {other:?}"),
}
}
#[test]
fn script_entry_unknown_shell_variant_rejected() {
let yaml = r#"
run: echo hello
shell: ruby
"#;
let err = serde_yaml::from_str::<ScriptEntry>(yaml)
.expect_err("unknown shell variant must be rejected");
let msg = format!("{err}");
assert!(
msg.contains("did not match any variant"),
"error should indicate parse failure: {msg}"
);
}
#[test]
fn script_shell_roundtrip_serialization() {
let entry = ScriptEntry::Full {
run: "make build".into(),
timeout: None,
idle_timeout: None,
continue_on_error: None,
shell: ScriptShell::Bash,
};
let yaml = serde_yaml::to_string(&entry).unwrap();
assert!(
yaml.contains("shell: bash"),
"yaml should contain 'shell: bash': {yaml}"
);
let roundtripped: ScriptEntry = serde_yaml::from_str(&yaml).unwrap();
assert_eq!(entry, roundtripped);
}
#[test]
fn script_shell_auto_not_serialized() {
let entry = ScriptEntry::Full {
run: "echo hi".into(),
timeout: None,
idle_timeout: None,
continue_on_error: None,
shell: ScriptShell::Auto,
};
let yaml = serde_yaml::to_string(&entry).unwrap();
assert!(
!yaml.contains("shell"),
"Auto shell should be skipped in serialization: {yaml}"
);
}
}