yolk/
eggs_config.rs

1use normalize_path::NormalizePath;
2use std::{
3    collections::{HashMap, HashSet},
4    path::{Path, PathBuf},
5    process::Command,
6    str::FromStr,
7};
8
9use miette::{miette, IntoDiagnostic as _};
10use rhai::Dynamic;
11
12use crate::{script::rhai_error::RhaiError, util::PathExt as _};
13
14macro_rules! rhai_error {
15    ($($tt:tt)*) => {
16        RhaiError::Other(miette!($($tt)*))
17    };
18}
19
20/// How the contents of an egg should be deployed.
21#[derive(Debug, PartialEq, Eq, Clone, Copy, Default)]
22pub enum DeploymentStrategy {
23    /// Recursively traverse the directory structure until a directory / file doesn't exist yet, then symlink there.
24    /// This allows stow-like behavior.
25    Merge,
26    /// Simply deploy to the given target, or fail.
27    #[default]
28    Put,
29}
30
31impl FromStr for DeploymentStrategy {
32    type Err = miette::Error;
33
34    fn from_str(s: &str) -> Result<Self, Self::Err> {
35        match s {
36            "merge" => Ok(DeploymentStrategy::Merge),
37            "put" => Ok(DeploymentStrategy::Put),
38            _ => miette::bail!(
39                help = "strategy must be one of 'merge' or 'put'",
40                "Invalid deployment strategy {}",
41                s
42            ),
43        }
44    }
45}
46
47#[derive(Debug, Clone, PartialEq, Eq, Default)]
48pub struct ShellHooks {
49    pub post_deploy: Option<String>,
50    pub post_undeploy: Option<String>,
51    pub pre_deploy: Option<String>,
52    pub pre_undeploy: Option<String>,
53    // pub post_sync: Option<String>,
54}
55
56impl ShellHooks {
57    pub fn run_post_deploy(&self) -> miette::Result<()> {
58        if let Some(command) = &self.post_deploy {
59            tracing::debug!("Running post-deploy script");
60            run_hook(command)?;
61        }
62        Ok(())
63    }
64
65    pub fn run_post_undeploy(&self) -> miette::Result<()> {
66        if let Some(command) = &self.post_undeploy {
67            tracing::debug!("Running post-undeploy script");
68            run_hook(command)?;
69        }
70        Ok(())
71    }
72    pub fn run_pre_deploy(&self) -> miette::Result<()> {
73        if let Some(command) = &self.pre_deploy {
74            tracing::debug!("Running pre-deploy script");
75            run_hook(command)?;
76        }
77        Ok(())
78    }
79    pub fn run_pre_undeploy(&self) -> miette::Result<()> {
80        if let Some(command) = &self.pre_undeploy {
81            tracing::debug!("Running pre-undeploy script");
82            run_hook(command)?;
83        }
84        Ok(())
85    }
86
87    // pub fn run_post_sync(&self) -> miette::Result<()> {
88    //     if let Some(command) = &self.post_sync {
89    //         tracing::debug!("Running post-sync script");
90    //         run_hook(command)?;
91    //     }
92    //     Ok(())
93    // }
94}
95
96fn run_hook(command: &str) -> miette::Result<()> {
97    let status = Command::new("sh")
98        .arg("-c")
99        .arg(command)
100        .status()
101        .map_err(|e| miette::miette!("Failed to run post-deploy hook: {}", e))?;
102
103    if !status.success() {
104        miette::bail!(
105            "Post-deploy hook failed with status {}",
106            status.code().unwrap_or(-1)
107        );
108    }
109    Ok(())
110}
111
112#[derive(Debug, PartialEq, Eq, Clone)]
113pub struct EggConfig {
114    /// The targets map is a map from `path-relative-to-egg-dir` -> `path-where-it-should-go`.
115    pub targets: HashMap<PathBuf, PathBuf>,
116    pub enabled: bool,
117    pub templates: HashSet<PathBuf>,
118    /// The "main" file of this egg -- currently used to determine which path should be opened by `yolk edit`.
119    pub main_file: Option<PathBuf>,
120    pub strategy: DeploymentStrategy,
121    pub unsafe_shell_hooks: ShellHooks,
122}
123
124#[derive(Debug, PartialEq, Eq, Hash)]
125enum EggConfigKey {
126    Targets,
127    MainFile,
128    Strategy,
129    Templates,
130    Enabled,
131    UnsafeShellHooks,
132}
133
134impl EggConfigKey {
135    fn from_str(s: &str) -> Option<Self> {
136        match s {
137            "targets" => Some(EggConfigKey::Targets),
138            "main_file" => Some(EggConfigKey::MainFile),
139            "strategy" => Some(EggConfigKey::Strategy),
140            "templates" => Some(EggConfigKey::Templates),
141            "enabled" => Some(EggConfigKey::Enabled),
142            "unsafe_shell_hooks" => Some(EggConfigKey::UnsafeShellHooks),
143            _ => None,
144        }
145    }
146}
147
148#[derive(Debug, PartialEq, Eq, Hash)]
149enum ShellHookKey {
150    PostDeploy,
151    PostUndeploy,
152    PreDeploy,
153    PreUndeploy,
154}
155
156impl ShellHookKey {
157    fn from_str(s: &str) -> Option<Self> {
158        match s {
159            "post_deploy" => Some(ShellHookKey::PostDeploy),
160            "post_undeploy" => Some(ShellHookKey::PostUndeploy),
161            "pre_deploy" => Some(ShellHookKey::PreDeploy),
162            "pre_undeploy" => Some(ShellHookKey::PreUndeploy),
163            _ => None,
164        }
165    }
166}
167
168impl Default for EggConfig {
169    fn default() -> Self {
170        EggConfig {
171            enabled: true,
172            targets: HashMap::new(),
173            templates: HashSet::new(),
174            main_file: None,
175            strategy: Default::default(),
176            unsafe_shell_hooks: ShellHooks {
177                post_deploy: None,
178                post_undeploy: None,
179                pre_deploy: None,
180                pre_undeploy: None,
181            },
182        }
183    }
184}
185
186impl EggConfig {
187    pub fn new(in_egg: impl AsRef<Path>, deployed_to: impl AsRef<Path>) -> Self {
188        let in_egg = in_egg.as_ref();
189        EggConfig {
190            enabled: true,
191            targets: maplit::hashmap! {
192                in_egg.to_path_buf() => deployed_to.as_ref().to_path_buf()
193            },
194            templates: HashSet::new(),
195            main_file: None,
196            strategy: DeploymentStrategy::default(),
197            unsafe_shell_hooks: ShellHooks {
198                post_deploy: None,
199                post_undeploy: None,
200                pre_deploy: None,
201                pre_undeploy: None,
202            },
203        }
204    }
205
206    pub fn new_merge(in_egg: impl AsRef<Path>, deployed_to: impl AsRef<Path>) -> Self {
207        Self::new(in_egg, deployed_to).with_strategy(DeploymentStrategy::Merge)
208    }
209
210    pub fn with_unsafe_hooks(mut self, unsafe_shell_hooks: ShellHooks) -> Self {
211        self.unsafe_shell_hooks = unsafe_shell_hooks;
212        self
213    }
214
215    pub fn with_enabled(mut self, enabled: bool) -> Self {
216        self.enabled = enabled;
217        self
218    }
219
220    pub fn with_template(mut self, template: impl AsRef<Path>) -> Self {
221        self.templates.insert(template.as_ref().to_path_buf());
222        self
223    }
224
225    pub fn with_strategy(mut self, strategy: DeploymentStrategy) -> Self {
226        self.strategy = strategy;
227        self
228    }
229
230    pub fn with_main_file(mut self, main_file: impl AsRef<Path>) -> Self {
231        self.main_file = Some(main_file.as_ref().to_path_buf());
232        self
233    }
234
235    /// Add a new target from a path inside the egg dir to the path it should be deployed as.
236    pub fn with_target(mut self, in_egg: impl AsRef<Path>, deploy_to: impl AsRef<Path>) -> Self {
237        self.targets.insert(
238            in_egg.as_ref().to_path_buf(),
239            deploy_to.as_ref().to_path_buf(),
240        );
241        self
242    }
243
244    /// Returns the targets map, but with any `~` expanded to the home directory.
245    ///
246    /// The targets map is a map from `path-relative-to-egg-dir` -> `path-where-it-should-go`.
247    pub fn targets_expanded(
248        &self,
249        home: impl AsRef<Path>,
250        egg_root: impl AsRef<Path>,
251    ) -> miette::Result<HashMap<PathBuf, PathBuf>> {
252        let egg_root = egg_root.as_ref();
253        self.targets
254            .iter()
255            .map(|(source, target)| {
256                let source = egg_root.canonical()?.join(source);
257                let target = target.expanduser();
258                let target = if target.is_absolute() {
259                    target
260                } else {
261                    home.as_ref().join(target)
262                };
263                Ok((source.normalize(), target.normalize()))
264            })
265            .collect()
266    }
267
268    /// Expand the glob patterns in the `templates` field to a list of paths.
269    /// The globbed paths are considered relative to `in_dir`. The resulting list of paths will contain absolute paths.
270    pub fn templates_globexpanded(&self, in_dir: impl AsRef<Path>) -> miette::Result<Vec<PathBuf>> {
271        let in_dir = in_dir.as_ref();
272        let mut paths = Vec::new();
273        for globbed in &self.templates {
274            let expanded = glob::glob(&in_dir.join(globbed).to_string_lossy()).into_diagnostic()?;
275            for path in expanded {
276                paths.push(path.into_diagnostic()?);
277            }
278        }
279        Ok(paths)
280    }
281
282    pub fn from_dynamic(value: Dynamic) -> Result<Self, RhaiError> {
283        if let Ok(target_path) = value.as_immutable_string_ref() {
284            return Ok(EggConfig::new(".", target_path.to_string()));
285        }
286        let Ok(map) = value.as_map_ref() else {
287            return Err(rhai_error!("egg value must be a string or a map"));
288        };
289
290        for (k, _v) in map.iter() {
291            let k: &str = &*k;
292            if EggConfigKey::from_str(k).is_none() {
293                tracing::warn!("unknown egg config key: {}", k);
294            }
295        }
296
297        let empty_map = Dynamic::from(rhai::Map::new());
298        let targets = map.get("targets").unwrap_or(&empty_map);
299
300        let targets = if let Ok(targets) = targets.as_immutable_string_ref() {
301            maplit::hashmap! { PathBuf::from(".") => PathBuf::from(targets.to_string()) }
302        } else if let Ok(targets) = targets.as_map_ref() {
303            targets
304                .clone()
305                .into_iter()
306                .map(|(k, v)| {
307                    Ok::<_, RhaiError>((
308                        PathBuf::from(&*k),
309                        PathBuf::from(&v.into_string().map_err(|e| {
310                            rhai_error!("target file value must be a path, but got {e}")
311                        })?),
312                    ))
313                })
314                .collect::<Result<_, _>>()?
315        } else {
316            return Err(rhai_error!("egg `targets` must be a string or a map"));
317        };
318
319        let main_file = match map.get("main_file") {
320            Some(path) => Some(
321                path.as_immutable_string_ref()
322                    .map_err(|e| rhai_error!("main_file must be a path, but got {e}"))?
323                    .to_string()
324                    .into(),
325            ),
326            None => None,
327        };
328
329        let strategy = match map.get("strategy") {
330            Some(strategy) => {
331                DeploymentStrategy::from_str(&strategy.to_string()).map_err(RhaiError::Other)?
332            }
333            None => DeploymentStrategy::default(),
334        };
335
336        let templates =
337            if let Some(templates) = map.get("templates") {
338                templates
339                    .as_array_ref()
340                    .map_err(|t| rhai_error!("`templates` must be a list, but got {t}"))?
341                    .iter()
342                    .map(|x| {
343                        Ok::<_, RhaiError>(PathBuf::from(x.clone().into_string().map_err(|e| {
344                            rhai_error!("template entry must be a path, but got {e}")
345                        })?))
346                    })
347                    .collect::<Result<HashSet<_>, _>>()?
348            } else {
349                HashSet::new()
350            };
351
352        let enabled = if let Some(x) = map.get("enabled") {
353            x.as_bool()
354                .map_err(|t| rhai_error!("`enabled` must be a list, but got {t}"))?
355        } else {
356            true
357        };
358
359        let unsafe_shell_hooks = if let Some(x) = map.get("unsafe_shell_hooks") {
360            let shell_hooks = x
361                .as_map_ref()
362                .map_err(|t| rhai_error!("`unsafe_shell_hooks` must be a map, but got {t}"))?;
363
364            for (k, _v) in shell_hooks.iter() {
365                let k: &str = &*k;
366                if ShellHookKey::from_str(k).is_none() {
367                    tracing::warn!("unknown key: {}", k);
368                }
369            }
370            ShellHooks {
371                post_deploy: shell_hooks.get("post_deploy").map(|v| v.to_string()),
372                post_undeploy: shell_hooks.get("post_undeploy").map(|v| v.to_string()),
373                pre_deploy: shell_hooks.get("pre_deploy").map(|v| v.to_string()),
374                pre_undeploy: shell_hooks.get("pre_undeploy").map(|v| v.to_string()),
375            }
376        } else {
377            ShellHooks::default()
378        };
379
380        Ok(EggConfig {
381            targets,
382            enabled,
383            templates,
384            main_file,
385            strategy,
386            unsafe_shell_hooks,
387        })
388    }
389}
390
391#[cfg(test)]
392mod test {
393    use std::collections::HashSet;
394
395    use assert_fs::{
396        prelude::{FileWriteStr as _, PathChild as _},
397        TempDir,
398    };
399    use maplit::hashset;
400    use miette::IntoDiagnostic as _;
401    use pretty_assertions::assert_eq;
402
403    use crate::{
404        eggs_config::{DeploymentStrategy, EggConfig, ShellHooks},
405        util::test_util::TestResult,
406    };
407
408    use rstest::rstest;
409    #[rstest]
410    #[case(
411        indoc::indoc! {r#"
412            #{
413                enabled: false,
414                targets: #{ "foo": "~/bar" },
415                templates: ["foo"],
416                main_file: "foo",
417                strategy: "merge",
418                unsafe_shell_hooks: #{
419                    post_deploy: "run after deploy",
420                    post_undeploy: "run after undeploy",
421                    pre_deploy: "run before deploy",
422                    pre_undeploy: "run before undeploy",
423                }
424            }
425        "#},
426        EggConfig::new_merge("foo", "~/bar")
427            .with_enabled(false)
428            .with_template("foo")
429            .with_strategy(DeploymentStrategy::Merge)
430            .with_main_file("foo")
431            .with_unsafe_hooks(ShellHooks {
432                post_deploy: Some("run after deploy".to_string()),
433                post_undeploy: Some("run after undeploy".to_string()),
434                pre_deploy: Some("run before deploy".to_string()),
435                pre_undeploy: Some("run before undeploy".to_string()),
436            })
437    )]
438    #[case(r#"#{ targets: "~/bar" }"#, EggConfig::new(".", "~/bar"))]
439    #[case(r#""~/bar""#, EggConfig::new(".", "~/bar"))]
440    fn test_read_eggs_config(#[case] input: &str, #[case] expected: EggConfig) -> TestResult {
441        let result = rhai::Engine::new().eval(input)?;
442        assert_eq!(EggConfig::from_dynamic(result)?, expected);
443        Ok(())
444    }
445
446    #[test]
447    fn test_template_globbed() -> TestResult {
448        let home = TempDir::new().into_diagnostic()?;
449        let config = EggConfig::new_merge(home.to_str().unwrap(), ".")
450            .with_template("foo")
451            .with_template("**/*.foo");
452        home.child("foo").write_str("a")?;
453        home.child("bar/baz/a.foo").write_str("a")?;
454        home.child("bar/a.foo").write_str("a")?;
455        home.child("bar/foo").write_str("a")?;
456        let result = config.templates_globexpanded(&home)?;
457
458        assert_eq!(
459            result.into_iter().collect::<HashSet<_>>(),
460            hashset![
461                home.child("foo").path().to_path_buf(),
462                home.child("bar/baz/a.foo").path().to_path_buf(),
463                home.child("bar/a.foo").path().to_path_buf(),
464            ]
465        );
466        Ok(())
467    }
468
469    #[test]
470    fn test_invalid_key_warns_and_parses() {
471        let input = r#"#{ unknown_key: "value" }"#;
472        let result = rhai::Engine::new().eval(input).unwrap();
473        let parsed = EggConfig::from_dynamic(result);
474        assert!(
475            parsed.is_ok(),
476            "Expected parsing to succeed (unknown keys are warnings)"
477        );
478        let cfg = parsed.unwrap();
479        assert_eq!(cfg, EggConfig::default());
480    }
481
482    #[test]
483    fn test_invalid_unsafe_shell_hooks_key_warns_and_parses() {
484        let input = indoc::indoc! {r#"
485            #{
486                unsafe_shell_hooks: #{
487                    not_a_real_hook: "do something"
488                }
489            }
490        "#};
491        let result = rhai::Engine::new().eval(input).unwrap();
492        let parsed = EggConfig::from_dynamic(result);
493        assert!(
494            parsed.is_ok(),
495            "Expected parsing to succeed (unknown hooks are warnings)"
496        );
497        let cfg = parsed.unwrap();
498        assert_eq!(cfg.unsafe_shell_hooks, ShellHooks::default());
499    }
500}