/ src / config.rs
config.rs
  1  //! Logic to interact with configuration file.
  2  use anyhow::anyhow;
  3  use serde::{Deserialize, Serialize};
  4  use std::{collections::HashMap, fs::{self, File}, io::{self, BufWriter}, path::{Path, PathBuf}, str::FromStr};
  5  use tempfile::tempdir;
  6  use walkdir::WalkDir;
  7  use zip::{write::{ExtendedFileOptions, FileOptions}, CompressionMethod, ZipArchive, ZipWriter};
  8  /// Type alias for the contents of a path.
  9  type Contents = HashMap<String, Node>;
 10  /// Contains the possible results of a node in the configuration.
 11  #[derive(Serialize, Deserialize)]
 12  enum Node {
 13      /// File definition.
 14      File {},
 15      /// Directory definition.
 16      Folder(Contents),
 17  }
 18  /// Contains all configuration information for the program.
 19  ///
 20  /// # Fields
 21  /// - `path`: The path of the configuration file.
 22  /// - `username`: The username to use in place of $USER.
 23  /// - `root`: The file hierarchy stored in the configuration file.
 24  #[derive(Serialize, Deserialize)]
 25  pub struct Config {
 26      path: PathBuf,
 27      username: String,
 28      root: Contents,
 29  }
 30  impl Config {
 31      /// Reads the configuration file from the path. 
 32      /// If the file doesn't exist, an empty configuration is made.
 33      ///
 34      /// # Fields
 35      /// - `config_path`:
 36      /// - `username`:
 37      ///
 38      /// # Examples
 39      /// ```
 40      /// // TODO: Config::new() example
 41      /// ```
 42      pub fn new(config_path: PathBuf, username: Option<String>) -> anyhow::Result<Self> {
 43          let path = config_path.clone();
 44          let username = username.unwrap_or(whoami::username());
 45          let mut config = match config_path.exists() {
 46              true => Self {
 47                  path,
 48                  username,
 49                  root: serde_json::from_str(&fs::read_to_string(&config_path)?).unwrap_or_default(),
 50              },
 51              false => {
 52                  File::create(&config_path)?;
 53                  Self {
 54                      path,
 55                      username,
 56                      root: HashMap::new(),
 57                  }
 58              },
 59          };
 60          config.add_path(&config_path)?;
 61          Ok(config)
 62      }
 63      /// Reads the configuration file from the default location ({os_config_dir}/dotfiles.conf).
 64      ///
 65      /// # Fields
 66      /// - `username`:
 67      ///
 68      /// # Examples
 69      /// ```
 70      /// // TODO: Config::default_config() example
 71      /// ```
 72      pub fn default_config(username: Option<String>) -> anyhow::Result<Self> {
 73          match dirs::config_dir() {
 74              Some(path) => Self::new(path.join("dotfiles.conf"), username) ,
 75              None => Err(anyhow!("Current user does not have a home directory")),
 76          }
 77      }
 78      /// Adds a path to the configuration.
 79      ///
 80      /// # Fields
 81      /// - `path`: The path to be added.
 82      ///
 83      /// # Examples
 84      /// ```
 85      /// // TODO: add_path() example
 86      /// ```
 87      pub fn add_path(&mut self, path: &PathBuf) -> anyhow::Result<()> {
 88          let path = fs::canonicalize(path)?;
 89          match path {
 90              path if path.is_file() => self.add_file(&path),
 91              path if path.is_dir() => self.add_dir(&path),
 92              _ => Err(anyhow!("Attempted to read config from {}, but path does not exist", path.to_string_lossy())),
 93          }?;
 94          self.save()
 95      }
 96      /// Removes a path from the configuration.
 97      ///
 98      /// # Fields
 99      /// - `path`: The path to be removed.
100      ///
101      /// # Examples
102      /// ```
103      /// // TODO: remove_path() example
104      /// ```
105      pub fn remove_path(&mut self, path: &PathBuf) -> anyhow::Result<()> {
106          let path = fs::canonicalize(path)?;
107          let components = self.username_to_placeholder(&path);
108          Self::remove_recursive(&mut self.root, &components);
109          self.save()
110      }
111      /// Exports the configuration to a zip archive.
112      ///
113      /// # Fields
114      /// - `path`: The path for the zip archive to be made at.
115      /// - `overwrite`: Overwrite existing files.
116      ///
117      /// # Examples
118      /// ```
119      /// // TODO: export() example
120      /// ```
121      pub fn export(&self, path: &Option<PathBuf>, overwrite: bool) -> anyhow::Result<()> {
122          let path = get_path(path)?;
123          match (path.exists(), overwrite) {
124              (true, false) => Err(anyhow!("The requested export path already exists. If this was intentional rerun with the overwrite flag")),
125              _ => self.make_archive(&path),
126          }
127      }
128      /// Installs a configuration from a zip archive.
129      /// # Fields 
130      /// - `source_path`: The path of the zip archive to be installed.
131      /// - `destination_path`: The base path for the extraction.
132      ///
133      /// # Examples
134      /// ```
135      /// //TODO: extract_from_zip() example
136      /// ```
137      pub fn extract_from_zip(&self, source_path: &Path, destination_path: &Path) -> anyhow::Result<()> {
138          let mut archive = ZipArchive::new(File::open(source_path)?)?;
139          for i in 0..archive.len() {
140              let mut file = archive.by_index(i)?;
141              if file.name().eq("README.md") || file.name().eq("LICENSE") { continue; }
142              let path = self.placeholder_to_username(&Path::new(destination_path).join(file.name()));
143              match file.is_dir() { 
144                  true => fs::create_dir_all(&path)?, 
145                  false => {
146                      if let Some(parent) = path.parent() { fs::create_dir_all(parent)?; }
147                      io::copy(&mut file, &mut File::create(&path)?)?;
148                  }
149              }
150          }
151          Ok(())
152      }
153      /// Converts username in path to placeholder string "$USER".
154      ///
155      /// # Fields
156      /// - `path`: The path to be updated.
157      /// 
158      /// # Examples
159      /// ```
160      /// // TODO: username_to_placeholder() example
161      /// ```
162      fn username_to_placeholder(&self, path: &PathBuf) -> Vec<String> {
163          path.iter().map(|part| {
164              let part = part.to_string_lossy().to_string();
165              match part.eq(&self.username) {
166                  true => "$USER".to_string(),
167                  false => part,
168              }
169          }).collect()
170      }
171      /// Converts placeholder string "$USER" in path to username.
172      ///
173      /// # Fields
174      /// - `path`: The path to be updated.
175      ///
176      /// # Examples
177      /// ```
178      /// // TODO: placeholder_to_username() example
179      /// ```
180      fn placeholder_to_username(&self, path: &PathBuf) -> PathBuf {
181          path.iter().map(|part| {
182              let part = part.to_string_lossy().to_string();
183              match part.eq("$USER") {
184                  true => self.username.to_string(),
185                  false => part,
186              }
187          }).collect()
188      }
189      /// Add a file to the configuration.
190      ///
191      /// # Fields
192      /// - `file_path`: The path of the file to be added.
193      ///
194      /// # Examples
195      /// ```
196      /// // TODO: add_file() example
197      /// ```
198      fn add_file(&mut self, file_path: &PathBuf) -> anyhow::Result<()> {
199          let components = self.username_to_placeholder(file_path);
200          let mut current = &mut self.root;
201          for (i, part) in components.iter().enumerate() {
202              let is_last = i == file_path.iter().count() - 1;
203              if !current.contains_key(part) {
204                  match is_last {
205                      true => current.insert(part.clone(), Node::File {}),
206                      false => current.insert(part.clone(), Node::Folder(HashMap::new()))
207                  };
208              }
209              match current.get_mut(part) {
210                  Some(Node::Folder(map)) => { current = map; },
211                  Some(Node::File {}) if is_last => return Ok(()),
212                  Some(Node::File {}) => return Err(anyhow!("{} is file, but a directory was expected at this path", part)),
213                  None => unreachable!(),
214              }
215          }
216          Ok(())
217      }
218      /// Add a directory to the configuration.
219      ///
220      /// # Fields
221      /// - `dir_path`: The path of the directory to be added.
222      ///
223      /// # Examples
224      /// ```
225      /// // TODO: add_dir() example
226      /// ```
227      fn add_dir(&mut self, dir_path: &PathBuf) -> anyhow::Result<()> {
228          for entry in fs::read_dir(dir_path)? {
229              self.add_path(&entry?.path())?;
230          }
231          Ok(())
232      }
233      /// Remove a Node from the configuration recursively.
234      ///
235      /// # Fields
236      /// - `map`: The configuration map to edit.
237      /// - `components`: The nodes to be removed.
238      ///
239      /// # Examples
240      /// ```
241      /// // TODO: remove_recursive() example
242      /// ```
243      fn remove_recursive(map: &mut Contents, components: &Vec<String>) -> bool {
244          if components.is_empty() { return false; }
245          let key = &components[0];
246  
247          if components.len() == 1 { map.remove(key).is_some() }
248          else if let Some(Node::Folder(child_map)) = map.get_mut(key) {
249              let removed = Self::remove_recursive(child_map, &components[1..].to_vec());
250              if child_map.is_empty() { map.remove(key); }
251              removed
252          }
253          else { false }
254      }
255      /// Save the configuration to it's file path.
256      ///
257      /// # Examples
258      /// ```
259      /// // TODO: save() example
260      /// ```
261      fn save(&self) -> anyhow::Result<()> {
262          let file = File::create(&self.path)?;
263          serde_json::to_writer_pretty(BufWriter::new(file), &self.root)?;
264          Ok(())
265      }
266      /// Produce a zip archive that contains the referenced files in the configuration.
267      ///
268      /// # Fields
269      /// - `archive_path`: The path of the new archive.
270      ///
271      /// # Examples
272      /// ```
273      /// // TODO: make_archive() example
274      /// ```
275      fn make_archive(&self, archive_path: &PathBuf) -> anyhow::Result<()> {
276          let tmp_dir = tempdir()?;
277          self.copy_files_recursive(&Path::new("/"), tmp_dir.path(), &self.root)?;
278          compress_to_zip(&tmp_dir.path(), &archive_path)?;
279          tmp_dir.close()?;
280          Ok(())
281      }
282      /// Recursively copy files from a path to a new location.
283      /// This function only copies files that are also stored in the configuration file.
284      ///
285      /// # Fields
286      /// - `source_path`: The path to copy files from.
287      /// - `destination_path`: The base path to copy the files to.
288      /// - `node`: The node in the configuration that matches the source_path.
289      ///
290      /// # Examples
291      /// ```
292      /// //TODO: copy_files_recursive() example
293      /// ```
294      fn copy_files_recursive(&self, source_path: &Path, destination_path: &Path, node: &Contents) -> anyhow::Result<()> {
295          for (name, subnode) in node {
296              let name = match name {
297                  name if name.eq("/") => "",
298                  _ => name,
299              };
300              let new_src = self.placeholder_to_username(&source_path.join(name));
301              let new_dst = destination_path.join(&name);
302              match subnode {
303                  Node::File {} => { fs::copy(new_src, new_dst)?; },
304                  Node::Folder(child_map) => {
305                      if !name.eq("/") { fs::create_dir_all(&new_dst)?; }
306                      self.copy_files_recursive(&new_src, &new_dst, child_map)?;
307                  }
308              }
309          }
310          Ok(())
311      }
312  }
313  /// Reads the path.
314  /// If none, returns the default (./dotfiles.zip).
315  /// Otherwise, returns the path.
316  ///
317  /// # Fields:
318  /// - `path`: The path to be read.
319  ///
320  /// # Examples
321  /// ```
322  /// // TODO: get_path() example
323  /// ```
324  fn get_path(path: &Option<PathBuf>) -> anyhow::Result<PathBuf> {
325      let mut path = match path {
326          Some(path) => path.clone(),
327          None => PathBuf::from_str("./dotfiles.zip")?,
328      };
329      match path.extension() {
330          Some(_ext) => (),
331          None => { path.set_extension("zip"); },
332      };
333      Ok(path)
334  }
335  /// Compresses a path to a zip archive at the destination path.
336  ///
337  /// # Fields
338  /// - `source_path`: The path to be compressed.
339  /// - `destination_path`: The path the archive should be made at.
340  ///
341  /// # Examples
342  /// ```
343  /// //TODO: compress_to_zip() example
344  /// ```
345  fn compress_to_zip(source_path: &Path, destination_path: &Path) -> anyhow::Result<()> {
346      let mut zip_writer = ZipWriter::new(File::create(destination_path)?);
347      let options: FileOptions<'_, ExtendedFileOptions> = FileOptions::default()
348          .compression_method(CompressionMethod::Deflated)
349          .unix_permissions(0o755);
350      for entry in WalkDir::new(source_path) {
351          let entry = entry?;
352          let path = entry.path();
353          let rel_path = path.strip_prefix(source_path)?.to_string_lossy();
354  
355          if path.is_file() {
356              zip_writer.start_file(rel_path, options.clone())?;
357              let mut f = File::open(path)?;
358              io::copy(&mut f, &mut zip_writer)?;
359          } else if path.is_dir() { 
360              zip_writer.add_directory(rel_path, options.clone())?;
361          }
362      }
363      zip_writer.finish()?;
364      Ok(())
365  }