Skip to main content

dotm/
config.rs

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