dotfilers/
executor.rs

1use crate::config::{Condition, ConflictStrategy, Directive, DirectiveStep, Os, StateConfig};
2use crate::LinkDirectoryBehaviour;
3use anyhow::{anyhow, Context, Result};
4use fs_extra::dir::CopyOptions;
5use std::path::{Path, PathBuf};
6use std::process::Command;
7use tera::{Context as TeraContext, Tera};
8
9pub trait OsDetector {
10    fn get_os(&self) -> Result<Os>;
11}
12
13pub struct RealOsDetector;
14
15impl OsDetector for RealOsDetector {
16    fn get_os(&self) -> Result<Os> {
17        Ok(Os::Linux)
18    }
19}
20
21pub struct Executor<T>
22where
23    T: OsDetector,
24{
25    pub dry_run: bool,
26    pub shell: String,
27    pub os_detector: T,
28    pub conflict_strategy: ConflictStrategy,
29}
30
31impl Executor<RealOsDetector> {
32    pub fn dry_run(shell: &str, conflict_strategy: ConflictStrategy) -> Self {
33        Self::create(true, shell, conflict_strategy)
34    }
35
36    pub fn new(shell: &str, conflict_strategy: ConflictStrategy) -> Self {
37        Self::create(false, shell, conflict_strategy)
38    }
39
40    fn create(dry_run: bool, shell: &str, conflict_strategy: ConflictStrategy) -> Self {
41        Self {
42            shell: shell.to_string(),
43            dry_run,
44            os_detector: RealOsDetector,
45            conflict_strategy,
46        }
47    }
48}
49
50impl<T> Executor<T>
51where
52    T: OsDetector,
53{
54    pub fn execute<P: AsRef<Path>>(&self, root_dir: P, section: &str, directives: &[DirectiveStep]) -> Result<()> {
55        let root_dir = root_dir.as_ref();
56        if self.dry_run {
57            info!("Using root_dir: {}", root_dir.display());
58        } else {
59            debug!("Using root_dir: {}", root_dir.display());
60        }
61
62        for directive in directives {
63            debug!("Executing [section={}] [directive={:?}]", section, directive);
64            self.execute_directive(root_dir, directive)?;
65        }
66
67        info!("Executed section {}", section);
68        Ok(())
69    }
70
71    fn execute_directive(&self, root_dir: &Path, directive: &DirectiveStep) -> Result<()> {
72        match &directive.condition {
73            Condition::Always => debug!("Directive has no condition. Executing"),
74            Condition::IfOs(os) => {
75                let current_os = self.os_detector.get_os().context("Error detecting current OS")?;
76                if &current_os == os {
77                    debug!("OS match. Executing");
78                } else {
79                    debug!("OS does not match. Not executing");
80                    return Ok(());
81                }
82            }
83        }
84
85        match &directive.directive {
86            Directive::Link {
87                from,
88                to,
89                directory_behaviour,
90            } => {
91                debug!(
92                    "Link directive [from={}] [to={}] [behaviour={}]",
93                    from,
94                    to,
95                    directory_behaviour.to_string()
96                );
97                self.execute_symlink(root_dir, from, to, directory_behaviour)?;
98            }
99            Directive::Copy { from, to } => {
100                debug!("Copy directive [from={}] [to={}]", from, to);
101                self.execute_copy(root_dir, from, to)?;
102            }
103            Directive::Run(cmd) => {
104                debug!("Run directive [cmd={}]", cmd);
105                self.run(root_dir, cmd)?;
106            }
107            Directive::Include(path) => {
108                debug!("Include directive [path={}]", path);
109                self.include(root_dir, path)?;
110            }
111            Directive::Template { template, dest, vars } => {
112                debug!("Template directive [template={}] [dest={}] [vars={:?}]", template, dest, vars);
113                self.template(root_dir, template, dest, vars)?;
114            }
115        }
116
117        Ok(())
118    }
119
120    fn execute_symlink(&self, root_dir: &Path, from: &str, to: &str, behaviour: &LinkDirectoryBehaviour) -> Result<()> {
121        let paths = self
122            .get_paths_to_process(root_dir, from, to)
123            .context("Error obtaining paths to process")?;
124        let remove_dirs = behaviour.ne(&LinkDirectoryBehaviour::CreateDirectory);
125        for (from, to) in paths {
126            let (from_path, to_path) = self
127                .check_for_conflicts(root_dir, &from, &to, remove_dirs)
128                .context("Error in symlink prerequirements")?;
129            if from_path.is_dir() {
130                match behaviour {
131                    LinkDirectoryBehaviour::IgnoreDirectories => {
132                        if self.dry_run {
133                            info!(
134                                "Skipping dir {} as LinkDirectoryBehaviour is set to IgnoreDirectories",
135                                from_path.display()
136                            );
137                        } else {
138                            debug!(
139                                "Skipping dir {} as LinkDirectoryBehaviour is set to IgnoreDirectories",
140                                from_path.display()
141                            );
142                        }
143                    }
144                    LinkDirectoryBehaviour::LinkDirectory => {
145                        if self.dry_run {
146                            info!("Would symlink dir {} -> {}", from_path.display(), to_path.display());
147                        } else {
148                            symlink::symlink_dir(&from_path, &to_path).context(format!(
149                                "Error symlinking dir {} -> {}",
150                                from_path.display(),
151                                to_path.display()
152                            ))?;
153                            info!("Symlinked dir {} -> {}", from_path.display(), to_path.display());
154                        }
155                    }
156                    LinkDirectoryBehaviour::CreateDirectory => {
157                        if !to_path.exists() {
158                            if self.dry_run {
159                                info!(
160                                    "Would create dir {} as LinkDirectoryBehaviour is set to CreateDirectory",
161                                    to_path.display()
162                                );
163                            } else {
164                                debug!(
165                                    "Creating dir {} as LinkDirectoryBehaviour is set to CreateDirectory",
166                                    to_path.display()
167                                );
168                                std::fs::create_dir(&to_path).context(format!("Error creating directory {}", to_path.display()))?;
169                                info!("Created dir {}", to_path.display());
170                            }
171                        } else if self.dry_run {
172                            info!("To path already exists, no need to do anything {}", to_path.display());
173                        } else {
174                            debug!("To path already exists, no need to do anything {}", to_path.display());
175                        }
176
177                        // Now recurse in files inside from
178                        let from_files =
179                            std::fs::read_dir(&from_path).context(format!("Error getting dir contents of {}", from_path.display()))?;
180                        for entry in from_files {
181                            let entry = entry.context(format!("Error getting entry of dir {}", from_path.display()))?;
182                            let entry = entry.path();
183                            let entry_without_prefix = entry
184                                .strip_prefix(&root_dir)
185                                .context(format!(
186                                    "Error stripping prefix from entry [entry={}] [prefix={}]",
187                                    entry.display(),
188                                    root_dir.display()
189                                ))?
190                                .to_path_buf();
191                            let from_path = entry_without_prefix.display().to_string();
192                            let from_filename = match entry.file_name() {
193                                Some(f) => match f.to_str() {
194                                    Some(filename) => filename.to_string(),
195                                    None => return Err(anyhow!("Cannot convert to str {:?}", f)),
196                                },
197                                None => return Err(anyhow!("Cannot obtain filename from {}", entry.display())),
198                            };
199                            let to_path = format!("{}/{}", to, from_filename);
200                            self.execute_symlink(root_dir, &from_path, &to_path, behaviour)?;
201                        }
202                    }
203                }
204            } else if self.dry_run {
205                info!("Would symlink file {} -> {}", from_path.display(), to_path.display());
206            } else {
207                symlink::symlink_file(&from_path, &to_path).context(format!(
208                    "Error symlinking file {} -> {}",
209                    from_path.display(),
210                    to_path.display()
211                ))?;
212                info!("Symlinked file {} -> {}", from_path.display(), to_path.display());
213            }
214        }
215
216        Ok(())
217    }
218
219    fn execute_copy(&self, root_dir: &Path, from: &str, to: &str) -> Result<()> {
220        let paths = self
221            .get_paths_to_process(root_dir, from, to)
222            .context("Error obtaining paths to process")?;
223        for (from, to) in paths {
224            let (from, to) = self
225                .check_for_conflicts(root_dir, &from, &to, true)
226                .context("Error in copy prerequirements")?;
227            if from.is_dir() {
228                if self.dry_run {
229                    info!("Would copy dir {} -> {}", from.display(), to.display());
230                } else {
231                    fs_extra::dir::copy(
232                        &from,
233                        &to,
234                        &CopyOptions {
235                            overwrite: true,
236                            ..CopyOptions::default()
237                        },
238                    )
239                    .context(format!("Error copying dir {} -> {}", from.display(), to.display()))?;
240                    info!("Copied dir {} -> {}", from.display(), to.display());
241                }
242            } else if self.dry_run {
243                info!("Would copy file {} -> {}", from.display(), to.display());
244            } else {
245                debug!("Copying {} -> {}", from.display(), to.display());
246                std::fs::copy(&from, &to).context(format!("Error copying file {} -> {}", from.display(), to.display()))?;
247                info!("Copied file {} -> {}", from.display(), to.display());
248            }
249        }
250
251        Ok(())
252    }
253
254    fn get_paths_to_process(&self, root_dir: &Path, from: &str, to: &str) -> Result<Vec<(String, String)>> {
255        let mut paths = vec![];
256        let to_dest = if to.contains('~') {
257            PathBuf::from(shellexpand::tilde(to).to_string())
258        } else {
259            root_dir.join(to)
260        };
261        if !is_glob(from) {
262            paths.push((from.to_string(), to_dest.display().to_string()));
263        } else {
264            debug!("Detected from is glob: {}", from);
265            if !to_dest.exists() {
266                // If we have been asked to copy a glob of files to a dir that does not exist, create the dir
267                if self.dry_run {
268                    info!("Would have created dir {}", to_dest.display());
269                } else {
270                    debug!("Creating dir {}", to_dest.display());
271                    std::fs::create_dir_all(&to_dest).context(format!("Error creating directory {}", to_dest.display()))?;
272                }
273            } else if !to_dest.is_dir() {
274                return Err(anyhow!("Asked to copy into a path that is not a directory"));
275            }
276
277            let full_glob = root_dir.join(from).display().to_string();
278            debug!("Detected from is glob {} | Will use {}", from, full_glob);
279            let glob_iter = glob::glob(&full_glob).context(format!("Error obtaining iterator from glob {}", full_glob))?;
280            for entry in glob_iter {
281                let entry = entry.context("Error obtaining glob entry")?;
282                let entry_without_prefix = entry
283                    .strip_prefix(&root_dir)
284                    .context("Error stripping prefix from glob")?
285                    .to_path_buf();
286                let from_path = entry_without_prefix.display().to_string();
287                let from_filename = match entry.file_name() {
288                    Some(f) => match f.to_str() {
289                        Some(filename) => filename.to_string(),
290                        None => return Err(anyhow!("Cannot convert to str {:?}", f)),
291                    },
292                    None => return Err(anyhow!("Cannot obtain filename from {}", entry.display())),
293                };
294                let to_path = format!("{}/{}", to, from_filename);
295                paths.push((from_path, to_path));
296            }
297        }
298        Ok(paths)
299    }
300
301    fn check_for_conflicts(&self, root_dir: &Path, from: &str, to: &str, delete_if_dir: bool) -> Result<(PathBuf, PathBuf)> {
302        // Check if from file exists
303        let from_path = root_dir.join(from);
304        let to_path = if to.contains('~') {
305            PathBuf::from(shellexpand::tilde(to).to_string())
306        } else {
307            root_dir.join(to)
308        };
309        debug!("Checking if 'from' exists: {}", from_path.display());
310
311        if !from_path.exists() {
312            return Err(anyhow!("From does not exist: {}", from_path.display()));
313        }
314
315        // Check if to already exists
316        debug!("Checking if 'to' exists: {}", to_path.display());
317        let mut to_already_exists = to_path.exists();
318        if !to_already_exists {
319            debug!(
320                "Detected 'to' does not exist. Checking if is a broken symlink {}",
321                to_path.display()
322            );
323            // Check for broken symlink
324            if std::fs::symlink_metadata(&to_path).is_ok() {
325                debug!("Detected 'to' is a broken symlink {}", to_path.display());
326                to_already_exists = true;
327            }
328        }
329
330        if to_already_exists {
331            debug!("'to' exists: {}", to_path.display());
332
333            // To already exists. Check conflict strategy
334            match &self.conflict_strategy {
335                ConflictStrategy::Abort => {
336                    warn!("ConflictStrategy set to abort. Aborting");
337                    return Err(anyhow!(
338                        "'to' {} already exists and ConflictStrategy is set to abort",
339                        to_path.display()
340                    ));
341                }
342                ConflictStrategy::RenameOld => {
343                    debug!("ConflictStrategy set to rename-old. Renaming old");
344
345                    let mut counter = 0;
346                    let backup_path = loop {
347                        let mut to_path_clone = to_path.display().to_string();
348                        let suffix = if counter == 0 {
349                            ".bak".to_string()
350                        } else {
351                            format!(".bak{}", counter)
352                        };
353                        to_path_clone.push_str(&suffix);
354
355                        let to_path_bak = Path::new(&to_path_clone);
356                        debug!("Checking if backup already exists");
357                        if !to_path_bak.exists() {
358                            break to_path_clone;
359                        }
360                        counter += 1;
361                    };
362
363                    let backup_path = Path::new(&backup_path);
364                    if self.dry_run {
365                        info!("Would move [src={}] [dst={}]", to_path.display(), backup_path.display());
366                    } else {
367                        warn!("Moving [src={}] -> [dst={}]", to_path.display(), backup_path.display());
368                        std::fs::rename(&to_path, backup_path).context(format!(
369                            "Error renaming [src={}] -> [dst={}]",
370                            to_path.display(),
371                            backup_path.display()
372                        ))?;
373                    }
374
375                    return Ok((from_path, to_path));
376                }
377                ConflictStrategy::Overwrite => {
378                    if to_path.is_symlink() {
379                        if to_path.is_file() {
380                            if self.dry_run {
381                                info!("Would remove file symlink {}", to_path.display());
382                            } else {
383                                warn!("Removing file symlink {}", to_path.display());
384                                symlink::remove_symlink_file(&to_path)
385                                    .context(format!("Error removing file symlink {}", to_path.display()))?;
386                            }
387                        } else if to_path.is_dir() {
388                            if delete_if_dir {
389                                if self.dry_run {
390                                    info!("Would remove dir symlink {}", to_path.display());
391                                } else {
392                                    warn!("Removing dir symlink {}", to_path.display());
393                                    symlink::remove_symlink_dir(&to_path)
394                                        .context(format!("Error removing dir symlink {}", to_path.display()))?;
395                                }
396                            } else if self.dry_run {
397                                info!(
398                                    "Would not remove dir symlink as is specified in configuration {}",
399                                    to_path.display()
400                                );
401                            } else {
402                                debug!("Not removing dir symlink as is specified in configuration {}", to_path.display());
403                            }
404                        } else {
405                            // Probably a broken symlink if is neither a file nor a dir
406                            if self.dry_run {
407                                info!("Would remove broken symlink {}", to_path.display());
408                            } else {
409                                warn!("Removing broken symlink {}", to_path.display());
410                                symlink::remove_symlink_file(&to_path).context("Error removing broken symlink")?;
411                            }
412                        }
413                    } else if to_path.is_file() {
414                        if self.dry_run {
415                            info!("Would remove file {}", to_path.display());
416                        } else {
417                            warn!("Removing file {}", to_path.display());
418                            std::fs::remove_file(&to_path).context(format!("Error removing file {}", to_path.display()))?;
419                        }
420                    } else if to_path.is_dir() {
421                        if delete_if_dir {
422                            if self.dry_run {
423                                info!("Would remove dir {}", to_path.display());
424                            } else {
425                                warn!("Removing dir {}", to_path.display());
426                                std::fs::remove_dir_all(&to_path).context(format!("Error removing dir {}", to_path.display()))?;
427                            }
428                        } else if self.dry_run {
429                            info!("Would not remove dir as is specified in configuration {}", to_path.display());
430                        } else {
431                            debug!("Not removing dir as is specified in configuration {}", to_path.display());
432                        }
433                    } else if self.dry_run {
434                        info!("Would remove dir {}", to_path.display());
435                    } else {
436                        warn!("Removing dir {}", to_path.display());
437                        std::fs::remove_dir_all(&to_path).context(format!("Error removing dir {}", to_path.display()))?;
438                    }
439                }
440            }
441        } else {
442            debug!("To {} does not exist", to_path.display());
443            // Check if parent dir structure exists
444            if let Some(parent) = to_path.parent() {
445                if !parent.exists() {
446                    if self.dry_run {
447                        info!("As parent dir does not exist, would have created {}", parent.display());
448                    } else {
449                        debug!("Creating parent dir structure {}", parent.display());
450                        std::fs::create_dir_all(&parent).context(format!("Error creating parent dir structure {}", parent.display()))?;
451                    }
452                }
453            }
454        }
455
456        Ok((from_path, to_path))
457    }
458
459    fn run(&self, root_dir: &Path, cmd: &str) -> Result<()> {
460        let current_dir = Path::new(root_dir);
461        let shell_args = self.shell.split(' ').collect::<Vec<&str>>();
462        if shell_args.is_empty() {
463            return Err(anyhow!("Cannot run commands with an empty shell definition"));
464        }
465
466        let mut command = Command::new(shell_args[0]);
467        for arg in shell_args.iter().skip(1) {
468            command.arg(arg);
469        }
470        command.arg(cmd).current_dir(current_dir);
471
472        if self.dry_run {
473            info!(
474                "Would run [current_dir={}]: {:?} {:?}",
475                root_dir.display(),
476                command.get_program(),
477                command.get_args()
478            );
479        } else {
480            debug!("Command to be executed: {:?} {:?}", command.get_program(), command.get_args());
481            let mut exec = command.spawn().context("Error invoking subcommand")?;
482            let exit_status = exec.wait().context("Error waiting for subcommand to finish")?;
483            if let Some(code) = exit_status.code() {
484                debug!("Command exit status: {}", code);
485                if code != 0 {
486                    return Err(anyhow!(
487                        "Command exit status was not 0. Exit status: {} | Command: {:?} {:?}",
488                        exit_status,
489                        command.get_program(),
490                        command.get_args()
491                    ));
492                }
493            }
494
495            info!("Executed command {}", cmd);
496        }
497
498        Ok(())
499    }
500
501    fn include(&self, root_dir: &Path, path: &str) -> Result<()> {
502        let yaml_path = root_dir.join(path);
503        if !yaml_path.exists() {
504            return Err(anyhow!("Could not file yaml to include in path {}", yaml_path.display()));
505        }
506
507        let contents = std::fs::read_to_string(&yaml_path).context(format!("Error loading included file {}", yaml_path.display()))?;
508        let config = StateConfig::from_yaml(&contents).context(format!("Error parsing included file {}", yaml_path.display()))?;
509
510        let included_root_dir = match yaml_path.parent() {
511            Some(p) => p,
512            None => root_dir,
513        };
514        debug!("Using root_dir: {}", included_root_dir.display());
515
516        for (section, directives) in config.states {
517            self.execute(included_root_dir, &section, &directives)
518                .context(format!("Error executing directives from file {}", yaml_path.display()))?;
519        }
520
521        info!("Finished include section {}", path);
522
523        Ok(())
524    }
525
526    fn template(&self, root_dir: &Path, template: &str, dest: &str, vars: &Option<String>) -> Result<()> {
527        let (template, dest) = self
528            .check_for_conflicts(root_dir, template, dest, true)
529            .context("Error preparing files for templating")?;
530
531        let template_contents =
532            std::fs::read_to_string(&template).context(format!("Error reading template contents: {}", template.display()))?;
533        let mut tera = Tera::default();
534        let mut context = TeraContext::new();
535        // Add default variables
536        let os = self.os_detector.get_os().context("Error detecting current os")?;
537        context.insert("dotfilers_os", &os.to_string());
538        load_vars_into_context(root_dir, vars, &mut context).context("Error loading template vars")?;
539
540        let rendered = tera.render_str(&template_contents, &context).context("Error rendering template")?;
541
542        if self.dry_run {
543            info!("Would have written into {} the following template: {}", dest.display(), rendered);
544        } else {
545            debug!("Writing template into {}", dest.display());
546            std::fs::write(&dest, rendered).context(format!("Error writing templated contents into {}", dest.display()))?;
547            info!("Rendered file {}", dest.display());
548        }
549
550        Ok(())
551    }
552}
553
554fn is_glob(path: &str) -> bool {
555    path.contains('*')
556}
557
558fn load_vars_into_context(root_dir: &Path, vars: &Option<String>, context: &mut TeraContext) -> Result<()> {
559    let vars = match vars {
560        Some(v) => v,
561        None => return Ok(()),
562    };
563
564    let vars_path = root_dir.join(vars);
565
566    if !vars_path.exists() {
567        return Err(anyhow!("Could not find vars file {}", vars_path.display()));
568    }
569
570    if !vars_path.is_file() {
571        return Err(anyhow!("Vars file is not a file {}", vars_path.display()));
572    }
573
574    let vars_contents = std::fs::read_to_string(&vars_path).context(format!("Error reading vars file {}", vars_path.display()))?;
575    load_vars_into_context_from_str(&vars_contents, context);
576
577    Ok(())
578}
579
580fn load_vars_into_context_from_str(contents: &str, context: &mut TeraContext) {
581    for line in contents.lines() {
582        if line.is_empty() || line.starts_with('#') {
583            continue;
584        }
585        let splits = line.split_once('=');
586        if let Some((name, value)) = splits {
587            context.insert(name.to_string(), &value.to_string());
588        }
589    }
590}
591
592#[cfg(test)]
593mod test {
594    use super::*;
595
596    #[test]
597    fn load_vars_into_context() {
598        let mut context = TeraContext::new();
599        load_vars_into_context_from_str(
600            r#"
601name=test
602number=123
603withequals=a=b=c
604# contents=abc
605        "#,
606            &mut context,
607        );
608
609        assert_eq!(context.get("name"), Some(&tera::Value::String("test".to_string())));
610        assert_eq!(context.get("number"), Some(&tera::Value::String("123".to_string())));
611        assert_eq!(context.get("withequals"), Some(&tera::Value::String("a=b=c".to_string())));
612
613        // Assert there are only 3 sections, as the comment is ignored
614        let as_json = context.into_json();
615        let v = as_json.as_object().unwrap();
616        assert_eq!(v.len(), 3);
617    }
618}