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#[derive(Debug, PartialEq, Eq, Clone, Copy, Default)]
22pub enum DeploymentStrategy {
23 Merge,
26 #[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 }
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 }
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 pub targets: HashMap<PathBuf, PathBuf>,
116 pub enabled: bool,
117 pub templates: HashSet<PathBuf>,
118 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 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 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 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}