Skip to main content

dotm/
config.rs

1use serde::Deserialize;
2use std::collections::HashMap;
3use toml::map::Map;
4use toml::Value;
5
6#[derive(Debug, Deserialize)]
7pub struct RootConfig {
8    pub dotm: DotmSettings,
9    #[serde(default)]
10    pub packages: HashMap<String, PackageConfig>,
11}
12
13#[derive(Debug, Deserialize)]
14pub struct DotmSettings {
15    pub target: String,
16    #[serde(default = "default_packages_dir")]
17    pub packages_dir: String,
18    #[serde(default)]
19    pub auto_prune: bool,
20}
21
22fn default_packages_dir() -> String {
23    "packages".to_string()
24}
25
26#[derive(Debug, Deserialize, PartialEq, Eq, Clone, Copy)]
27#[serde(rename_all = "lowercase")]
28pub enum DeployStrategy {
29    Stage,
30    Copy,
31}
32
33#[derive(Debug, Default, Deserialize)]
34pub struct PackageConfig {
35    pub description: Option<String>,
36    #[serde(default)]
37    pub depends: Vec<String>,
38    #[serde(default)]
39    pub suggests: Vec<String>,
40    pub target: Option<String>,
41    pub strategy: Option<DeployStrategy>,
42    #[serde(default)]
43    pub permissions: HashMap<String, String>,
44    #[serde(default)]
45    pub system: bool,
46    pub owner: Option<String>,
47    pub group: Option<String>,
48    #[serde(default)]
49    pub ownership: HashMap<String, String>,
50    #[serde(default)]
51    pub preserve: HashMap<String, Vec<String>>,
52    pub pre_deploy: Option<String>,
53    pub post_deploy: Option<String>,
54    pub pre_undeploy: Option<String>,
55    pub post_undeploy: Option<String>,
56}
57
58pub fn validate_system_packages(root: &RootConfig) -> Vec<String> {
59    let mut errors = Vec::new();
60    for (name, pkg) in &root.packages {
61        if pkg.system && pkg.target.is_none() {
62            errors.push(format!(
63                "system package '{name}' must specify a target directory"
64            ));
65        }
66        // Validate ownership format
67        for (path, value) in &pkg.ownership {
68            if value.split(':').count() != 2 {
69                errors.push(format!(
70                    "package '{name}': invalid ownership format for '{path}': expected 'user:group', got '{value}'"
71                ));
72            }
73        }
74        // Validate permissions format
75        for (path, value) in &pkg.permissions {
76            if u32::from_str_radix(value, 8).is_err() {
77                errors.push(format!(
78                    "package '{name}': invalid permission for '{path}': '{value}' is not valid octal"
79                ));
80            }
81        }
82        // Validate preserve entries don't conflict
83        for (path, preserve_fields) in &pkg.preserve {
84            for field in preserve_fields {
85                match field.as_str() {
86                    "owner" | "group" => {
87                        if pkg.ownership.contains_key(path) {
88                            errors.push(format!(
89                                "package '{name}': file '{path}' has both preserve {field} and ownership override"
90                            ));
91                        }
92                    }
93                    "mode" => {
94                        if pkg.permissions.contains_key(path) {
95                            errors.push(format!(
96                                "package '{name}': file '{path}' has both preserve mode and permission override"
97                            ));
98                        }
99                    }
100                    other => {
101                        errors.push(format!(
102                            "package '{name}': file '{path}': unknown preserve field '{other}'"
103                        ));
104                    }
105                }
106            }
107        }
108    }
109    errors
110}
111
112pub fn deprecated_strategy_warnings(root: &RootConfig) -> Vec<String> {
113    let mut warnings = Vec::new();
114    for (name, pkg) in &root.packages {
115        if pkg.strategy.is_some() {
116            warnings.push(format!(
117                "warning: 'strategy' field on package '{name}' is deprecated and ignored; deployment mode is now determined automatically"
118            ));
119        }
120    }
121    warnings
122}
123
124#[derive(Debug, Deserialize)]
125pub struct HostConfig {
126    pub hostname: String,
127    pub roles: Vec<String>,
128    #[serde(default)]
129    pub vars: Map<String, Value>,
130}
131
132#[derive(Debug, Deserialize)]
133pub struct RoleConfig {
134    pub packages: Vec<String>,
135    #[serde(default)]
136    pub vars: Map<String, Value>,
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142
143    #[test]
144    fn validate_system_packages_does_not_require_strategy() {
145        let toml_str = r#"
146[dotm]
147target = "~"
148
149[packages.sys]
150system = true
151target = "/etc/sys"
152"#;
153        let root: RootConfig = toml::from_str(toml_str).unwrap();
154        let errors = validate_system_packages(&root);
155        assert!(errors.is_empty(), "unexpected errors: {:?}", errors);
156    }
157
158    #[test]
159    fn strategy_field_still_parses() {
160        let toml_str = r#"
161[dotm]
162target = "~"
163
164[packages.sys]
165system = true
166target = "/etc/sys"
167strategy = "copy"
168"#;
169        let root: RootConfig = toml::from_str(toml_str).unwrap();
170        assert!(root.packages["sys"].strategy.is_some());
171    }
172
173    #[test]
174    fn deprecated_strategy_warning_emitted() {
175        let toml_str = r#"
176[dotm]
177target = "~"
178
179[packages.shell]
180strategy = "stage"
181"#;
182        let root: RootConfig = toml::from_str(toml_str).unwrap();
183        let warnings = deprecated_strategy_warnings(&root);
184        assert_eq!(warnings.len(), 1);
185        assert!(warnings[0].contains("shell"));
186        assert!(warnings[0].contains("deprecated"));
187    }
188
189    #[test]
190    fn no_deprecation_warning_without_strategy() {
191        let toml_str = r#"
192[dotm]
193target = "~"
194
195[packages.shell]
196"#;
197        let root: RootConfig = toml::from_str(toml_str).unwrap();
198        let warnings = deprecated_strategy_warnings(&root);
199        assert!(warnings.is_empty());
200    }
201}