Skip to main content

dotfiles_actions/link/
action.rs

1// Copyright (c) 2021-2026 Miguel Barreto and others
2//
3// Permission is hereby granted, free of charge, to any person obtaining
4// a copy of this software and associated documentation files (the
5// "Software"), to deal in the Software without restriction, including
6// without limitation the rights to use, copy, modify, merge, publish,
7// distribute, sublicense, and/or sell copies of the Software, and to
8// permit persons to whom the Software is furnished to do so, subject to
9// the following conditions:
10//
11// The above copyright notice and this permission notice shall be
12// included in all copies or substantial portions of the Software.
13//
14// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18// LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20// WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
21
22//! This module contains the [LinkAction] that creates a new symlink
23//! when executed
24
25use derivative::Derivative;
26use dotfiles_core::error::DotfilesError;
27use dotfiles_core::error::ErrorType;
28use dotfiles_core::path::convert_path_to_absolute;
29use dotfiles_core::path::process_home_dir_in_path;
30use dotfiles_core::settings::{initialize_settings_object, Setting, Settings};
31use dotfiles_core::yaml_util::{self, get_boolean_setting_from_context};
32use dotfiles_core::{action::SKIP_IN_CI_SETTING, Action};
33use filesystem::FakeFileSystem;
34use filesystem::FileSystem;
35use filesystem::OsFileSystem;
36use filesystem::UnixFileSystem;
37use getset::CopyGetters;
38use getset::Getters;
39use log::error;
40use log::info;
41use std::format;
42use std::io;
43use std::io::ErrorKind;
44use std::path::Path;
45use std::path::PathBuf;
46use strict_yaml_rust::StrictYaml;
47
48/// Path setting (path of the symlink)
49pub const PATH_SETTING: &str = "path";
50/// Target setting (path to the file the symlink points to)
51pub const TARGET_SETTING: &str = "target";
52/// Force setting, replaces any other file or directory
53pub const FORCE_SETTING: &str = "force";
54/// Relink setting, if true the action relinks an existing symlink
55/// (applies if force is false)
56pub const RELINK_SETTING: &str = "relink";
57/// Create parent dirs if they don't exist
58pub const CREATE_PARENT_DIRS_SETTING: &str = "create_parent_dirs";
59/// Create the symlink even if the target file does not exist
60pub const IGNORE_MISSING_TARGET_SETTING: &str = "ignore_missing_target";
61/// Resolves the target if it is a symlink and uses the final target file as the target.
62pub const RESOLVE_SYMLINK_TARGET_SETTING: &str = "resolve_symlink_target";
63
64/// Initialize the defaults for the LinkAction.
65pub fn default_settings() -> Settings {
66  initialize_settings_object(&[
67    (FORCE_SETTING.to_owned(), Setting::Boolean(false)),
68    (RELINK_SETTING.to_owned(), Setting::Boolean(false)),
69    (
70      CREATE_PARENT_DIRS_SETTING.to_owned(),
71      Setting::Boolean(false),
72    ),
73    (
74      IGNORE_MISSING_TARGET_SETTING.to_owned(),
75      Setting::Boolean(false),
76    ),
77    (
78      RESOLVE_SYMLINK_TARGET_SETTING.to_owned(),
79      Setting::Boolean(false),
80    ),
81    (SKIP_IN_CI_SETTING.to_owned(), Setting::Boolean(false)),
82  ])
83}
84
85/// [LinkAction] creates a new symlink `path` that points to `target`.
86///
87/// It is equivalent to running `ln -s <target> <path>`
88#[derive(Derivative, Getters, CopyGetters)]
89#[derivative(Debug, PartialEq)]
90pub struct LinkAction<F: FileSystem + UnixFileSystem> {
91  /// Skips this action if it is running in a CI environment.
92  skip_in_ci: bool,
93  /// FileSystem to use to create the directory.
94  ///
95  /// Having a filesystem instance here allows us to use fakes/mocks to use
96  /// in unit tests.
97  #[derivative(Debug = "ignore", PartialEq = "ignore")]
98  fs: F,
99  /// Path of the new symlink
100  #[getset(get = "pub")]
101  path: String,
102  /// Path that the symlink points to.
103  #[getset(get = "pub")]
104  target: String,
105  /// Force to re-create the symlink if it exists already
106  #[getset(get_copy = "pub")]
107  relink: bool,
108  /// Force to replace an existing file or directory when executed.
109  #[getset(get_copy = "pub")]
110  force: bool,
111  /// Create all parent directories if they do not exist already
112  #[getset(get_copy = "pub")]
113  create_parent_dirs: bool,
114  /// Succeed even if `target` doesn't point to an existing file or dir.
115  #[getset(get_copy = "pub")]
116  ignore_missing_target: bool,
117  /// If the target is another symlink, resolve the ultimate concrete file
118  /// or directory that it points to and make it the target
119  #[getset(get_copy = "pub")]
120  resolve_symlink_target: bool,
121  /// Current directory that will be used to determine relative file locations if necessary. It
122  /// must match the parent directory of the configuration file that declared this action.
123  current_dir: PathBuf,
124}
125
126/// A native create action that works on the real filesystem.
127pub type NativeLinkAction = LinkAction<OsFileSystem>;
128/// A Fake create action that works on a fake test filesystem.
129pub type FakeLinkAction = LinkAction<FakeFileSystem>;
130
131impl<F: FileSystem + UnixFileSystem> LinkAction<F> {
132  /// Constructs a new [LinkAction]
133  #[allow(clippy::too_many_arguments)]
134  pub fn new(
135    fs: F,
136    path: String,
137    target: String,
138    context_settings: &'_ Settings,
139    defaults: &'_ Settings,
140    current_dir: PathBuf,
141  ) -> Result<Self, DotfilesError> {
142    let relink =
143      get_boolean_setting_from_context(RELINK_SETTING, context_settings, defaults).unwrap();
144    let force =
145      get_boolean_setting_from_context(FORCE_SETTING, context_settings, defaults).unwrap();
146    let create_parent_dirs =
147      get_boolean_setting_from_context(CREATE_PARENT_DIRS_SETTING, context_settings, defaults)
148        .unwrap();
149    let ignore_missing_target =
150      get_boolean_setting_from_context(IGNORE_MISSING_TARGET_SETTING, context_settings, defaults)
151        .unwrap();
152    let resolve_symlink_target =
153      get_boolean_setting_from_context(RESOLVE_SYMLINK_TARGET_SETTING, context_settings, defaults)
154        .unwrap();
155    let skip_in_ci =
156      get_boolean_setting_from_context(SKIP_IN_CI_SETTING, context_settings, defaults).unwrap();
157    let action = Self {
158      skip_in_ci,
159      fs,
160      path,
161      target,
162      relink,
163      force,
164      create_parent_dirs,
165      ignore_missing_target,
166      resolve_symlink_target,
167      current_dir,
168    };
169    log::trace!("Creating new {:?}", action);
170    Ok(action)
171  }
172}
173
174impl<F: FileSystem + UnixFileSystem> Action for LinkAction<F> {
175  fn execute(&self) -> Result<(), DotfilesError> {
176    fn create_symlink<F: FileSystem + UnixFileSystem>(
177      fs: &'_ F,
178      action: &'_ LinkAction<F>,
179      path: PathBuf,
180      mut target: PathBuf,
181    ) -> io::Result<()> {
182      let target_exists = fs.is_dir(&target) || fs.is_file(&target);
183      let path_exists = fs.is_dir(&path) || fs.is_file(&path);
184      let path_is_symlink = fs.get_symlink_src(&path).is_ok();
185      let target_is_symlink = fs.get_symlink_src(&target).is_ok();
186      if target_is_symlink && action.resolve_symlink_target() {
187        fn resolve_symlink_target<F: FileSystem + UnixFileSystem, P: AsRef<Path>>(
188          fs: &'_ F,
189          link_path: P,
190        ) -> io::Result<PathBuf> {
191          match fs.get_symlink_src(link_path.as_ref()) {
192            Ok(link_target) => resolve_symlink_target(fs, link_target),
193            Err(e) if [ErrorKind::IsADirectory, ErrorKind::InvalidInput].contains(&e.kind()) => {
194              Ok(PathBuf::from(link_path.as_ref()))
195            }
196            Err(e) => Err(e),
197          }
198        }
199        target = resolve_symlink_target(fs, &target)?
200      }
201      if target_exists || action.ignore_missing_target() {
202        if !fs.is_dir(path.parent().unwrap()) && action.create_parent_dirs() {
203          fs.create_dir_all(path.parent().unwrap())?
204        }
205        match (path_exists, action.force(), fs.is_dir(&path), path_is_symlink, action.relink()) {
206                        (true, true, true, _, _ ) =>fs.remove_dir_all(&path)?, // path exists, force, is_dir
207                        (true, true, false, _, _ ) =>fs.remove_file(&path)?, // path exists, force, is_file
208                        (true, false, _, true, true ) =>fs.remove_file(&path)?, // path exists, no force, is_symlink, relink
209                        (true, false, _, true, false) =>
210                            // path exists, no force, is symlink, no relink
211                            return Err(io::Error::new(
212                                ErrorKind::AlreadyExists,
213                                format!("{:?} already exists. Use `force` to delete a file/directory or `relink` to recreate a symlink", path))),
214                        _ => ()
215                    }
216        fs.symlink(&target, &path)
217      } else {
218        Err(io::Error::new(
219          ErrorKind::NotFound,
220          format!(
221            "Couldn't find target file {target:?} to link to, use `ignore_missing_target` to ignore",
222          ),
223        ))
224      }
225    }
226    let path: PathBuf = PathBuf::from(self.path());
227    let mut path = process_home_dir_in_path(&path);
228    path = convert_path_to_absolute(&path, Some(&self.current_dir))?;
229    let target = PathBuf::from(self.target());
230    let mut target = process_home_dir_in_path(&target);
231    target = convert_path_to_absolute(&target, Some(&self.current_dir))?;
232    match create_symlink(&self.fs, self, path, target) {
233      Ok(()) => {
234        info!("Created symlink {} -> {}", self.path, self.target);
235        Ok(())
236      }
237      Err(err) => {
238        error!(
239          "Couldn't create symlink {} -> {}: {}",
240          self.path, self.target, err
241        );
242        Err(DotfilesError::from(
243          err.to_string(),
244          ErrorType::FileSystemError { fs_error: err },
245        ))
246      }
247    }
248  }
249
250  fn skip_in_ci(&self) -> bool {
251    self.skip_in_ci
252  }
253}
254
255/// Static parsing function to build a LinkAction from YAML and settings context
256pub fn parse_action<F: FileSystem + UnixFileSystem + Clone>(
257  fs: F,
258  settings: &Settings,
259  yaml: &StrictYaml,
260  current_directory: &Path,
261) -> Result<LinkAction<F>, DotfilesError> {
262  parse_shortened_action(fs.clone(), settings, yaml, current_directory)
263    .or_else(|_| parse_full_action(fs, settings, yaml, current_directory))
264}
265
266fn parse_full_action<F: FileSystem + UnixFileSystem>(
267  fs: F,
268  context_settings: &Settings,
269  yaml: &StrictYaml,
270  current_dir: &Path,
271) -> Result<LinkAction<F>, DotfilesError> {
272  let defaults = default_settings();
273  let path = yaml_util::get_string_setting_from_yaml_or_context(
274    PATH_SETTING,
275    yaml,
276    context_settings,
277    &defaults,
278  )?;
279  let target = yaml_util::get_string_setting_from_yaml_or_context(
280    TARGET_SETTING,
281    yaml,
282    context_settings,
283    &defaults,
284  )?;
285  let action_settings: Result<Settings, DotfilesError> = defaults
286    .keys()
287    .map(|name| {
288      yaml_util::get_setting_from_yaml_hash_or_from_context(name, yaml, context_settings, &defaults)
289        .map(|setting| (name.to_owned(), setting))
290    })
291    .collect();
292
293  LinkAction::<F>::new(
294    fs,
295    path,
296    target,
297    &action_settings?,
298    &defaults,
299    current_dir.to_owned(),
300  )
301}
302
303fn parse_shortened_action<F: FileSystem + UnixFileSystem>(
304  fs: F,
305  context_settings: &Settings,
306  yaml: &StrictYaml,
307  current_dir: &Path,
308) -> Result<LinkAction<F>, DotfilesError> {
309  let defaults = default_settings();
310  if let StrictYaml::Hash(hash) = yaml {
311    match hash.len() {
312      1 => {
313        if let (StrictYaml::String(path), StrictYaml::String(target)) = hash.front().unwrap() {
314          LinkAction::<F>::new(
315            fs,
316            path.clone(),
317            target.clone(),
318            context_settings,
319            &defaults,
320            current_dir.to_owned()
321          )
322        } else {
323          Err(DotfilesError::from_wrong_yaml(
324                      "StrictYaml passed to configure a short Link action is not a hash of string to string, cant parse".into(),
325                      yaml.to_owned(), StrictYaml::Hash(Default::default())))
326        }
327      }
328
329      x => Err(DotfilesError::from(
330        format!(
331          "StrictYaml passed to configure a short Link action is a hash with {x} values, must be just 1",),
332        ErrorType::InconsistentConfigurationError,
333      )),
334    }
335  } else {
336    Err(DotfilesError::from_wrong_yaml(
337      "StrictYaml passed to configure a Link action is not a Hash".into(),
338      yaml.to_owned(),
339      StrictYaml::Hash(Default::default()),
340    ))
341  }
342}