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 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 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 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}