1use std::collections::HashMap;
2use std::path::PathBuf;
3
4use serde::{Deserialize, Serialize};
5
6use super::module::ScriptEntry;
7use super::source::{EnvVar, ShellAlias};
8use crate::errors::{ConfigError, Result};
9#[derive(Debug, Clone, Serialize, Deserialize)]
12#[serde(rename_all = "camelCase", deny_unknown_fields)]
13pub struct ProfileDocument {
14 pub api_version: String,
15 pub kind: String,
16 pub metadata: ProfileMetadata,
17 pub spec: ProfileSpec,
18}
19
20#[derive(Debug, Clone, Serialize, Deserialize)]
21#[serde(rename_all = "camelCase", deny_unknown_fields)]
22pub struct ProfileMetadata {
23 pub name: String,
24}
25
26#[derive(Debug, Clone, Default, Serialize, Deserialize)]
27#[serde(rename_all = "camelCase", deny_unknown_fields)]
28pub struct ProfileSpec {
29 #[serde(default)]
30 pub inherits: Vec<String>,
31
32 #[serde(default)]
33 pub modules: Vec<String>,
34
35 #[serde(default)]
36 pub env: Vec<EnvVar>,
37
38 #[serde(default)]
39 pub aliases: Vec<ShellAlias>,
40
41 #[serde(default)]
42 pub packages: Option<PackagesSpec>,
43
44 #[serde(default)]
45 pub files: Option<FilesSpec>,
46
47 #[serde(default)]
48 pub system: HashMap<String, serde_yaml::Value>,
49
50 #[serde(default)]
51 pub secrets: Vec<SecretSpec>,
52
53 #[serde(default)]
54 pub scripts: Option<ScriptSpec>,
55}
56
57#[derive(Debug, Clone, Default, Serialize, Deserialize)]
58#[serde(rename_all = "camelCase", deny_unknown_fields)]
59pub struct PackagesSpec {
60 #[serde(default)]
61 pub brew: Option<BrewSpec>,
62 #[serde(default)]
63 pub apt: Option<AptSpec>,
64 #[serde(default)]
65 pub cargo: Option<CargoSpec>,
66 #[serde(default)]
67 pub npm: Option<NpmSpec>,
68 #[serde(default)]
69 pub pipx: Vec<String>,
70 #[serde(default)]
71 pub dnf: Vec<String>,
72 #[serde(default)]
73 pub apk: Vec<String>,
74 #[serde(default)]
75 pub pacman: Vec<String>,
76 #[serde(default)]
77 pub zypper: Vec<String>,
78 #[serde(default)]
79 pub yum: Vec<String>,
80 #[serde(default)]
81 pub pkg: Vec<String>,
82 #[serde(default)]
83 pub snap: Option<SnapSpec>,
84 #[serde(default)]
85 pub flatpak: Option<FlatpakSpec>,
86 #[serde(default)]
87 pub nix: Vec<String>,
88 #[serde(default)]
89 pub go: Vec<String>,
90 #[serde(default)]
91 pub winget: Vec<String>,
92 #[serde(default)]
93 pub chocolatey: Vec<String>,
94 #[serde(default)]
95 pub scoop: Vec<String>,
96 #[serde(default)]
97 pub custom: Vec<CustomManagerSpec>,
98}
99
100impl PackagesSpec {
101 pub fn simple_list_mut(&mut self, manager: &str) -> Option<&mut Vec<String>> {
105 match manager {
106 "pipx" => Some(&mut self.pipx),
107 "dnf" => Some(&mut self.dnf),
108 "apk" => Some(&mut self.apk),
109 "pacman" => Some(&mut self.pacman),
110 "zypper" => Some(&mut self.zypper),
111 "yum" => Some(&mut self.yum),
112 "pkg" => Some(&mut self.pkg),
113 "nix" => Some(&mut self.nix),
114 "go" => Some(&mut self.go),
115 "winget" => Some(&mut self.winget),
116 "chocolatey" => Some(&mut self.chocolatey),
117 "scoop" => Some(&mut self.scoop),
118 _ => None,
119 }
120 }
121
122 pub fn simple_list(&self, manager: &str) -> Option<&[String]> {
125 match manager {
126 "pipx" => Some(&self.pipx),
127 "dnf" => Some(&self.dnf),
128 "apk" => Some(&self.apk),
129 "pacman" => Some(&self.pacman),
130 "zypper" => Some(&self.zypper),
131 "yum" => Some(&self.yum),
132 "pkg" => Some(&self.pkg),
133 "nix" => Some(&self.nix),
134 "go" => Some(&self.go),
135 "winget" => Some(&self.winget),
136 "chocolatey" => Some(&self.chocolatey),
137 "scoop" => Some(&self.scoop),
138 _ => None,
139 }
140 }
141
142 pub fn non_empty_simple_lists(&self) -> Vec<(&str, &[String])> {
144 let mut result = Vec::new();
145 for name in &[
146 "pipx",
147 "dnf",
148 "apk",
149 "pacman",
150 "zypper",
151 "yum",
152 "pkg",
153 "nix",
154 "go",
155 "winget",
156 "chocolatey",
157 "scoop",
158 ] {
159 if let Some(list) = self.simple_list(name)
160 && !list.is_empty()
161 {
162 result.push((*name, list));
163 }
164 }
165 result
166 }
167}
168
169#[derive(Debug, Clone, Default, Serialize, Deserialize)]
170#[serde(rename_all = "camelCase", deny_unknown_fields)]
171pub struct BrewSpec {
172 #[serde(default)]
173 pub file: Option<String>,
174 #[serde(default)]
175 pub taps: Vec<String>,
176 #[serde(default)]
177 pub formulae: Vec<String>,
178 #[serde(default)]
179 pub casks: Vec<String>,
180}
181
182#[derive(Debug, Clone, Default, Serialize, Deserialize)]
183#[serde(rename_all = "camelCase", deny_unknown_fields)]
184pub struct AptSpec {
185 #[serde(default)]
186 pub file: Option<String>,
187 #[serde(default)]
188 pub packages: Vec<String>,
189}
190
191#[derive(Debug, Clone, Default, Serialize, Deserialize)]
192#[serde(rename_all = "camelCase", deny_unknown_fields)]
193pub struct NpmSpec {
194 #[serde(default)]
195 pub file: Option<String>,
196 #[serde(default)]
197 pub global: Vec<String>,
198}
199
200#[derive(Debug, Clone, Default, PartialEq, Serialize)]
203pub struct CargoSpec {
204 #[serde(default, skip_serializing_if = "Option::is_none")]
205 pub file: Option<String>,
206 #[serde(default)]
207 pub packages: Vec<String>,
208}
209
210impl<'de> Deserialize<'de> for CargoSpec {
211 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
212 where
213 D: serde::Deserializer<'de>,
214 {
215 use serde::de;
216
217 #[derive(Deserialize)]
218 #[serde(rename_all = "camelCase", deny_unknown_fields)]
219 struct CargoSpecFull {
220 #[serde(default)]
221 file: Option<String>,
222 #[serde(default)]
223 packages: Vec<String>,
224 }
225
226 struct CargoSpecVisitor;
228
229 impl<'de> de::Visitor<'de> for CargoSpecVisitor {
230 type Value = CargoSpec;
231
232 fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
233 formatter.write_str("a list of package names or a map with file/packages keys")
234 }
235
236 fn visit_seq<A>(self, mut seq: A) -> std::result::Result<CargoSpec, A::Error>
237 where
238 A: de::SeqAccess<'de>,
239 {
240 let mut packages = Vec::new();
241 while let Some(item) = seq.next_element::<String>()? {
242 packages.push(item);
243 }
244 Ok(CargoSpec {
245 file: None,
246 packages,
247 })
248 }
249
250 fn visit_map<M>(self, map: M) -> std::result::Result<CargoSpec, M::Error>
251 where
252 M: de::MapAccess<'de>,
253 {
254 let full = CargoSpecFull::deserialize(de::value::MapAccessDeserializer::new(map))?;
255 Ok(CargoSpec {
256 file: full.file,
257 packages: full.packages,
258 })
259 }
260 }
261
262 deserializer.deserialize_any(CargoSpecVisitor)
263 }
264}
265
266#[derive(Debug, Clone, Default, Serialize, Deserialize)]
267#[serde(rename_all = "camelCase", deny_unknown_fields)]
268pub struct SnapSpec {
269 #[serde(default)]
270 pub packages: Vec<String>,
271 #[serde(default)]
272 pub classic: Vec<String>,
273}
274
275#[derive(Debug, Clone, Default, Serialize, Deserialize)]
276#[serde(rename_all = "camelCase", deny_unknown_fields)]
277pub struct FlatpakSpec {
278 #[serde(default)]
279 pub packages: Vec<String>,
280 #[serde(default)]
281 pub remote: Option<String>,
282}
283
284#[derive(Debug, Clone, Serialize, Deserialize)]
285#[serde(rename_all = "camelCase", deny_unknown_fields)]
286pub struct CustomManagerSpec {
287 pub name: String,
288 pub check: String,
289 pub list_installed: String,
290 pub install: String,
291 pub uninstall: String,
292 #[serde(default)]
293 pub update: Option<String>,
294 #[serde(default)]
295 pub packages: Vec<String>,
296}
297
298#[derive(Debug, Clone, Default, Serialize, Deserialize)]
299#[serde(rename_all = "camelCase", deny_unknown_fields)]
300pub struct FilesSpec {
301 #[serde(default)]
302 pub managed: Vec<ManagedFileSpec>,
303 #[serde(default)]
304 pub permissions: HashMap<String, String>,
305}
306
307#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
309pub enum FileStrategy {
310 #[default]
312 Symlink,
313 Copy,
315 Template,
317 Hardlink,
319}
320
321#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
323pub enum EncryptionMode {
324 #[default]
326 InRepo,
327 Always,
329}
330
331#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
333#[serde(rename_all = "camelCase", deny_unknown_fields)]
334pub struct EncryptionSpec {
335 pub backend: String,
337 #[serde(default)]
339 pub mode: EncryptionMode,
340}
341
342#[derive(Debug, Clone, Default, Serialize, Deserialize)]
344#[serde(rename_all = "camelCase", deny_unknown_fields)]
345pub struct EncryptionConstraint {
346 #[serde(default)]
348 pub required_targets: Vec<String>,
349 #[serde(default, skip_serializing_if = "Option::is_none")]
351 pub backend: Option<String>,
352 #[serde(default, skip_serializing_if = "Option::is_none")]
354 pub mode: Option<EncryptionMode>,
355}
356
357#[derive(Debug, Clone, Serialize, Deserialize)]
358#[serde(rename_all = "camelCase", deny_unknown_fields)]
359pub struct ManagedFileSpec {
360 pub source: String,
361 pub target: PathBuf,
362 #[serde(default, skip_serializing_if = "Option::is_none")]
364 pub strategy: Option<FileStrategy>,
365 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
368 pub private: bool,
369 #[serde(skip)]
372 pub origin: Option<String>,
373 #[serde(default, skip_serializing_if = "Option::is_none")]
375 pub encryption: Option<EncryptionSpec>,
376 #[serde(default, skip_serializing_if = "Option::is_none")]
378 pub permissions: Option<String>,
379}
380
381#[derive(Debug, Clone, Serialize, Deserialize)]
382#[serde(rename_all = "camelCase", deny_unknown_fields)]
383pub struct SecretSpec {
384 pub source: String,
385 #[serde(default, skip_serializing_if = "Option::is_none")]
386 pub target: Option<PathBuf>,
387 #[serde(default, skip_serializing_if = "Option::is_none")]
388 pub template: Option<String>,
389 #[serde(default, skip_serializing_if = "Option::is_none")]
390 pub backend: Option<String>,
391 #[serde(default, skip_serializing_if = "Option::is_none")]
392 pub envs: Option<Vec<String>>,
393}
394
395pub fn validate_secret_specs(specs: &[SecretSpec]) -> Result<()> {
397 for spec in specs {
398 if spec.target.is_none() && spec.envs.as_ref().is_none_or(|e| e.is_empty()) {
399 return Err(ConfigError::Invalid {
400 message: format!(
401 "secret '{}' must have at least one of 'target' or 'envs'",
402 spec.source
403 ),
404 }
405 .into());
406 }
407 }
408 Ok(())
409}
410
411#[derive(Debug, Clone, Default, Serialize, Deserialize)]
412#[serde(rename_all = "camelCase", deny_unknown_fields)]
413pub struct ScriptSpec {
414 #[serde(default)]
415 pub pre_apply: Vec<ScriptEntry>,
416 #[serde(default)]
417 pub post_apply: Vec<ScriptEntry>,
418 #[serde(default)]
419 pub pre_reconcile: Vec<ScriptEntry>,
420 #[serde(default)]
421 pub post_reconcile: Vec<ScriptEntry>,
422 #[serde(default)]
423 pub on_drift: Vec<ScriptEntry>,
424 #[serde(default)]
425 pub on_change: Vec<ScriptEntry>,
426}
427
428#[cfg(test)]
429mod tests {
430 use super::*;
431
432 #[test]
433 fn profile_spec_rejects_unknown_field() {
434 let yaml = "modules: []\nbogus: 1\n";
435 let err = serde_yaml::from_str::<ProfileSpec>(yaml)
436 .expect_err("expected deny_unknown_fields to reject bogus");
437 assert!(format!("{}", err).contains("unknown field"));
438 }
439
440 #[test]
441 fn packages_spec_rejects_typo_for_known_manager() {
442 let yaml = "brwe:\n formulae: [ripgrep]\n";
444 let err = serde_yaml::from_str::<PackagesSpec>(yaml)
445 .expect_err("expected deny_unknown_fields to reject brwe typo");
446 let msg = format!("{}", err);
447 assert!(
448 msg.contains("unknown field") && msg.contains("brwe"),
449 "expected unknown-field error mentioning brwe, got: {msg}"
450 );
451 }
452
453 #[test]
454 fn managed_file_spec_rejects_unknown_field() {
455 let yaml = "source: a\ntarget: /tmp/b\nbogus: 1\n";
456 let err = serde_yaml::from_str::<ManagedFileSpec>(yaml)
457 .expect_err("expected deny_unknown_fields to reject bogus");
458 assert!(format!("{}", err).contains("unknown field"));
459 }
460}