/ src / lib.rs
lib.rs
  1  use std::collections::HashSet;
  2  use std::ffi::OsStr;
  3  use std::fs::{self};
  4  use std::io::{self};
  5  use std::path::{Path, PathBuf};
  6  
  7  use tracing::{debug, info, trace, warn};
  8  use walkdir::WalkDir;
  9  
 10  pub struct Dotr {
 11      ignore: HashSet<PathBuf>,
 12  
 13      dry_run: bool,
 14      force: bool,
 15  }
 16  
 17  impl Dotr {
 18      pub fn new() -> Self {
 19          Dotr {
 20              ignore: HashSet::new(),
 21              dry_run: false,
 22              force: false,
 23          }
 24      }
 25  
 26      pub fn set_force(self) -> Self {
 27          Self {
 28              force: true,
 29              ..self
 30          }
 31      }
 32  
 33      pub fn set_dry_run(self) -> Self {
 34          Self {
 35              dry_run: true,
 36              ..self
 37          }
 38      }
 39  
 40      pub fn link_entry(
 41          &self,
 42          src: &walkdir::DirEntry,
 43          src_base: &Path,
 44          dst_base: &Path,
 45      ) -> io::Result<()> {
 46          trace!(path = %src.path().display(), "Walking path");
 47  
 48          let src = src.path();
 49          let src_rel = src.strip_prefix(src_base).unwrap();
 50  
 51          if self.ignore.contains(src_rel) {
 52              debug!(path = %src.display(), "Ignoring file");
 53              return Ok(());
 54          }
 55  
 56          let dst = dst_base.join(src_rel);
 57          let dst_metadata = dst.symlink_metadata().ok();
 58          let dst_type = dst_metadata.map(|m| m.file_type());
 59  
 60          let src_metadata = src.symlink_metadata()?;
 61          let src_type = src_metadata.file_type();
 62  
 63          if src_type.is_dir() {
 64              return Ok(());
 65          } else if src_type.is_file() {
 66              trace!(src = %src.display(), dst=%dst.display(), "Source is a file");
 67              if dst.exists() || dst.symlink_metadata().is_ok() {
 68                  if self.force {
 69                      if dst_type.is_some_and(|t| t.is_dir()) {
 70                          io::Error::new(
 71                              io::ErrorKind::Other,
 72                              format!("Can't safely remove {} as it's a directory", dst.display()),
 73                          );
 74                      }
 75                      if !self.dry_run {
 76                          debug!(src = %src.display(), dst=%dst.display(), "Force removing destination");
 77                          fs::remove_file(&dst)?;
 78                      } else {
 79                          debug!(src = %src.display(), dst=%dst.display(), "Force removing destination (dry-run)");
 80                      }
 81                  } else {
 82                      if dst_type.map(|t| t.is_symlink()).unwrap_or(false) {
 83                          let dst_link_dst = dst.read_link()?;
 84                          if *dst_link_dst == *src {
 85                              debug!(src = %src.display(), dst=%dst.display(), "Destination already points to the source");
 86                              return Ok(());
 87                          } else {
 88                              warn!(src = %src.display(), dst = %dst.display(), dst_dst = %dst_link_dst.display(), "Destination already exists and points elsewhere");
 89                          }
 90                      } else {
 91                          warn!(src = %src.display(), dst=%dst.display(),  "Destination already exists and is not a symlink");
 92                      }
 93                      return Ok(());
 94                  }
 95              } else if !self.dry_run {
 96                  trace!(src = %src.display(), dst=%dst.display(), "Creating a base directory (if doesn't exist)");
 97                  fs::create_dir_all(dst.parent().unwrap())?;
 98              }
 99  
100              if !self.dry_run {
101                  trace!(src = %src.display(), dst=%dst.display(), "Creating symlink to a src file");
102                  std::os::unix::fs::symlink(src, &dst)?;
103              }
104          } else if src_type.is_symlink() {
105              let src_link = src.read_link()?;
106              trace!(src = %src.display(), dst=%dst.display(), "src-link" = %src_link.display(), "Source is a symlink");
107              if dst.exists() || dst.symlink_metadata().is_ok() {
108                  if self.force {
109                      if !self.dry_run {
110                          debug!(src = %src.display(), dst = %dst.display(), "Force removing destination");
111                          fs::remove_file(&dst)?;
112                      } else {
113                          debug!(src = %src.display(), dst = %dst.display(), "Force removing destination (dry-run)");
114                      }
115                  } else if Some(src_link.clone()) == dst.read_link().ok() {
116                      debug!(
117                          src = %src.display(), dst = %dst.display(),
118                          "Destination already points to the source (symlink source)"
119                      );
120                      return Ok(());
121                  } else {
122                      warn!(src = %src.display(), dst = %dst.display(), "Destination already exists");
123                      return Ok(());
124                  }
125              } else if !self.dry_run {
126                  trace!(src = %src.display(), dst = %dst.display(), "Creating a base directory (if doesn't exist)");
127                  fs::create_dir_all(dst.parent().unwrap())?;
128              }
129              if !self.dry_run {
130                  trace!(src = %src.display(), dst = %dst.display(), "src-link" = %src_link.display(), "Duplicating symlink");
131                  std::os::unix::fs::symlink(&src_link, &dst)?;
132              }
133          } else {
134              warn!(src = %src.display(), dst = %dst.display(), "Skipping unknown source file type");
135          }
136          Ok(())
137      }
138  
139      pub fn link(&self, src_base: &Path, dst_base: &Path) -> io::Result<()> {
140          info!(src = %src_base.display(), dst = %dst_base.display(), "Starting link operation");
141  
142          if !dst_base.exists() {
143              return Err(io::Error::new(
144                  io::ErrorKind::NotFound,
145                  "Destination doesn't exist",
146              ));
147          }
148  
149          if !dst_base.is_dir() {
150              return Err(io::Error::new(
151                  io::ErrorKind::AlreadyExists,
152                  "Destination is not a directory",
153              ));
154          }
155  
156          let dst_base = dst_base.canonicalize()?;
157          let src_base = src_base.canonicalize()?;
158  
159          assert!(dst_base.is_absolute());
160          assert!(src_base.is_absolute());
161  
162          for src in WalkDir::new(&src_base)
163              .into_iter()
164              .filter_entry(should_traverse)
165              .filter_map(|e| e.ok())
166          {
167              self.link_entry(&src, &src_base, &dst_base)?;
168          }
169  
170          Ok(())
171      }
172  
173      pub fn unlink(&self, src_base: &Path, dst_base: &Path) -> io::Result<()> {
174          info!(src = %src_base.display(), dst = %dst_base.display(), "Starting unlink operation");
175  
176          let dst_base = dst_base.canonicalize()?;
177          let src_base = src_base.canonicalize()?;
178  
179          assert!(dst_base.is_absolute());
180          assert!(src_base.is_absolute());
181  
182          for src in WalkDir::new(&src_base)
183              .into_iter()
184              .filter_entry(should_traverse)
185              .filter_map(|e| e.ok())
186          {
187              self.unlink_entry(&src, &src_base, &dst_base)?;
188          }
189  
190          Ok(())
191      }
192  
193      pub fn unlink_entry(
194          &self,
195          src: &walkdir::DirEntry,
196          src_base: &Path,
197          dst_base: &Path,
198      ) -> io::Result<()> {
199          trace!(path = %src.path().display(), "Walking path");
200  
201          let src = src.path();
202          let src_rel = src.strip_prefix(src_base).unwrap();
203  
204          if self.ignore.contains(src_rel) {
205              debug!(path = %src.display(), "Ignoring file");
206              return Ok(());
207          }
208  
209          let dst = dst_base.join(src_rel);
210  
211          let src_metadata = src.symlink_metadata()?;
212          let src_type = src_metadata.file_type();
213  
214          if src_type.is_dir() {
215              return Ok(());
216          } else if src_type.is_file() {
217              trace!(src = %src.display(), dst = %dst.display(), "Unlink a file");
218              let dst_metadata = dst.symlink_metadata();
219              // exists follows symlinks :/
220              if dst.exists() || dst_metadata.is_ok() {
221                  let dst_metadata = dst_metadata?;
222                  if self.force {
223                      if !self.dry_run {
224                          debug!(src = %src.display(), dst = %dst.display(), "Force removing");
225                          fs::remove_file(&dst)?;
226                          return Ok(());
227                      } else {
228                          debug!(src = %src.display(), dst = %dst.display(), "Force removing (dry run)");
229                      }
230                  } else if dst_metadata.file_type().is_file() {
231                      warn!(src = %src.display(), dst = %dst.display(), "Destination already exists and is a file");
232                      return Ok(());
233                  } else if dst_metadata.file_type().is_dir() {
234                      warn!(src = %src.display(), dst = %dst.display(), "Destination already exists and is a directory");
235                      return Ok(());
236                  } else if dst_metadata.file_type().is_symlink() {
237                      let dst_link = dst.read_link()?;
238                      if dst_link != src {
239                          warn!(src = %src.display(), dst = %dst.display(), "Destination already exists and is a symlink pointing to something else");
240                          return Ok(());
241                      } else if !self.dry_run {
242                          fs::remove_file(&dst)?;
243                      }
244                  } else {
245                      warn!(src = %src.display(), dst = %dst.display(), "Destination exists and is of unknown file type");
246                  }
247              } else {
248                  debug!(src = %src.display(), dst = %dst.display(), "Destination doesn't exist - nothing to unlink");
249                  return Ok(());
250              }
251          } else if src_type.is_symlink() {
252              let src_link = src.read_link()?;
253              trace!(src = %src.display(), dst = %dst.display(),  "Unlink a symlink");
254              let dst_metadata = dst.symlink_metadata();
255              // exists follows symlinks :/
256              if dst.exists() || dst_metadata.is_ok() {
257                  let dst_metadata = dst_metadata?;
258                  if self.force {
259                      if !self.dry_run {
260                          fs::remove_file(&dst)?;
261                          return Ok(());
262                      }
263                  } else if dst_metadata.file_type().is_file() {
264                      warn!(src = %src.display(), dst = %dst.display(),  "Destination already exists and is a file");
265                      return Ok(());
266                  } else if dst_metadata.file_type().is_dir() {
267                      warn!(src = %src.display(), dst = %dst.display(),  "Destination already exists and is a directory");
268                      return Ok(());
269                  } else if dst_metadata.file_type().is_symlink() {
270                      let dst_link = dst.read_link()?;
271                      if dst_link != src_link {
272                          warn!(
273                              src = %src.display(),
274                              dst = %dst.display(),
275                              "dst-link" = %dst_link.display(),
276                              "src-link" = %src_link.display(),
277                              "Destination already exists and is a symlink pointing to something else",
278                          );
279                          return Ok(());
280                      } else if !self.dry_run {
281                          fs::remove_file(&dst)?;
282                      }
283                  } else {
284                      warn!(src = %src.display(), dst = %dst.display(), "Destination exists and is of unknown file type");
285                  }
286              } else {
287                  debug!(src = %src.display(), dst = %dst.display(), "Destination doesn't exist - nothing to unlink");
288                  return Ok(());
289              }
290          } else {
291              warn!(src = %src.display(), dst = %dst.display(), "Skipping unknown source file type");
292          }
293          Ok(())
294      }
295  }
296  
297  impl Default for Dotr {
298      fn default() -> Self {
299          Self::new()
300      }
301  }
302  
303  fn should_traverse(de: &walkdir::DirEntry) -> bool {
304      if !de.path().is_dir() {
305          return true;
306      }
307  
308      if de.path().file_name() == Some(OsStr::new(".git")) {
309          return false;
310      }
311  
312      true
313  }