lib/config/
mod.rs

1pub mod cli;
2mod dotrc;
3
4use crate::{
5    common::{self, util, AbsolutePath, Platform},
6    verbose_println,
7};
8use derive_getters::Getters;
9use derive_more::From;
10use failure::Fail;
11use gethostname::gethostname;
12use globset::Glob;
13use lazy_static::lazy_static;
14use std::{
15    collections::HashSet,
16    ffi::OsStr,
17    path::{Path, PathBuf},
18    str::FromStr,
19};
20use walkdir::WalkDir;
21
22const DEFAULT_DOTFILES_DIR: &str = ".dotfiles";
23lazy_static! {
24    static ref DOTRC_NAMES: [&'static OsStr; 3] = [
25        OsStr::new(".dotrc"),
26        OsStr::new(".dotrc.yml"),
27        OsStr::new(".dotrc.yaml")
28    ];
29}
30
31/// All dotman configuration options
32#[derive(Debug, Getters)]
33pub struct Config {
34    excludes: Vec<AbsolutePath>,
35    tags: Vec<String>,
36    dotfiles_path: AbsolutePath,
37    hostname: String,
38    platform: Platform,
39    command: cli::Command,
40}
41
42#[derive(Debug)]
43enum PartialSource {
44    Cli,
45    Default,
46}
47
48/// Configuration options sans dotrc.
49///
50/// Can be used to guide dotrc discovery with `find_rcrc`.
51#[derive(Debug)]
52struct PartialConfig {
53    excludes: Vec<PathBuf>,
54    tags: Vec<String>,
55    dotfiles_path: (PathBuf, PartialSource),
56    hostname: (String, PartialSource),
57    platform: (Platform, PartialSource),
58    command: cli::Command,
59}
60
61impl PartialConfig {
62    fn merge(cli: cli::Config, default: DefaultConfig) -> Self {
63        let excludes = util::append_vecs(cli.excludes, default.excludes);
64        let tags = util::append_vecs(cli.tags, default.tags);
65
66        macro_rules! merge_with_source {
67            ($field: ident) => {
68                match cli.$field {
69                    Some($field) => ($field, PartialSource::Cli),
70                    None => (default.$field, PartialSource::Default),
71                }
72            };
73        }
74        let dotfiles_path = merge_with_source!(dotfiles_path);
75        let hostname = merge_with_source!(hostname);
76        let platform = merge_with_source!(platform);
77
78        let command = cli.command;
79
80        PartialConfig {
81            excludes,
82            tags,
83            dotfiles_path,
84            hostname,
85            platform,
86            command,
87        }
88    }
89
90    fn to_config(&self) -> Result<Config, walkdir::Error> {
91        let dotfiles_path = {
92            let (dotfiles_path, _) = &self.dotfiles_path;
93
94            AbsolutePath::from(dotfiles_path.clone())
95        };
96
97        let excludes = self
98            .excludes
99            .iter()
100            // Glob-expand
101            .map(|exclude| expand_glob(exclude, &dotfiles_path))
102            .collect::<Result<Vec<_>, _>>()?
103            .into_iter()
104            .flatten()
105            // Make each exclude path absolute by prepending them with the dotfiles path
106            .map(|exclude| AbsolutePath::from(dotfiles_path.join(exclude)))
107            .collect();
108
109        let tags = self.tags.clone();
110        let hostname = self.hostname.0.clone();
111        let platform = self.platform.0;
112        let command = self.command;
113
114        Ok(Config {
115            excludes,
116            tags,
117            dotfiles_path,
118            hostname,
119            platform,
120            command,
121        })
122    }
123}
124
125struct DefaultConfig {
126    excludes: Vec<PathBuf>,
127    tags: Vec<String>,
128    dotfiles_path: PathBuf,
129    hostname: String,
130    platform: Platform,
131}
132
133impl DefaultConfig {
134    /// Gets a partial configuration corresponding to the "default"
135    /// values/sources of each configuration option.
136    fn get() -> Result<DefaultConfig, Error> {
137        let excludes = vec![];
138        let tags = vec![];
139
140        let dotfiles_path = util::home_dir().join(DEFAULT_DOTFILES_DIR);
141
142        let hostname = gethostname().to_str().ok_or(NoSystemHostname)?.to_owned();
143
144        let platform = util::platform();
145
146        Ok(DefaultConfig {
147            excludes,
148            tags,
149            dotfiles_path,
150            hostname,
151            platform,
152        })
153    }
154}
155
156/// Tries to glob-expand `path`.
157/// If `PathBuf` -> `String` conversion fails or the pattern is invalid,
158/// fall back to simply not trying to glob-expand
159fn expand_glob(path: &Path, dotfiles_path: &AbsolutePath) -> Result<Vec<PathBuf>, walkdir::Error> {
160    // Just to improve whitespace in verbose output about glob expansion
161    let mut had_glob_output = false;
162    let mut glob_output = || {
163        if !had_glob_output {
164            had_glob_output = true;
165            verbose_println!();
166        }
167    };
168
169    let glob = match path.to_str().map(Glob::new) {
170        Some(Ok(glob)) => glob.compile_matcher(),
171        None | Some(Err(_)) => {
172            glob_output();
173            verbose_println!("Could not glob-expand {}", path.display());
174            return Ok(vec![PathBuf::from(path)]);
175        },
176    };
177
178    let entries: Vec<walkdir::DirEntry> = WalkDir::new(dotfiles_path)
179        .follow_links(true)
180        .into_iter()
181        .collect::<Result<_, _>>()?;
182
183    let expanded_paths: Vec<_> = entries
184        .into_iter()
185        .filter_map(|entry| {
186            let entry_path = entry
187                .path()
188                .strip_prefix(dotfiles_path)
189                .expect("Entry should be in the dotfiles path");
190
191            if glob.is_match(entry_path) {
192                Some(PathBuf::from(entry_path))
193            } else {
194                None
195            }
196        })
197        .collect();
198
199    // If an entry just got expanded to itself, don't print anything about it
200    match &expanded_paths.as_slice() {
201        [expanded_path] if expanded_path == path => (),
202        _ => {
203            glob_output();
204            verbose_println!("Glob-expanded {} to:", path.display());
205            for expanded_path in &expanded_paths {
206                verbose_println!("\t- {}", expanded_path.display())
207            }
208        },
209    }
210
211    Ok(expanded_paths)
212}
213
214/// Merges a partial config (obtained from the CLI and default settings) with a
215/// config obtained from reading the dotrc to create a complete configuration.
216fn merge_dotrc(
217    partial_config: PartialConfig,
218    dotrc_config: dotrc::Config,
219) -> Result<Config, Error> {
220    /// Merges an item from a `PartialConfig` and an item from a
221    /// `dotrc::Config`, making sure to respect the hierarchy of selecting
222    /// in the following order
223    /// - CLI
224    /// - dotrc
225    /// - Default source
226    fn merge_hierarchy<T>(partial: (T, PartialSource), dotrc: Option<T>) -> T {
227        match (partial, dotrc) {
228            ((x, PartialSource::Cli), _) => x,
229            (_, Some(x)) => x,
230            ((x, PartialSource::Default), None) => x,
231        }
232    }
233
234    /// If `path` begins with a tilde, attempts to expand it into the
235    /// full home directory path. If `path` doesn't start with a tilde, just
236    /// successfully returns path. Fails if the home directory cannot
237    /// be read as a `String`.
238    fn expand_tilde(path: String) -> Option<String> {
239        Some(
240            if path.starts_with('~') {
241                path.replacen("~", util::home_dir().to_str()?, 1)
242            } else {
243                path
244            },
245        )
246    }
247
248    let dotfiles_path = AbsolutePath::from(merge_hierarchy(
249        partial_config.dotfiles_path,
250        dotrc_config
251            .dotfiles_path
252            .and_then(expand_tilde)
253            .map(PathBuf::from),
254    ));
255
256    let excludes = {
257        let mut excludes: Vec<AbsolutePath> =
258            // Merge the excludes from partial_config (CLI + default) with the excludes from the dotrc
259            util::append_vecs(
260                partial_config.excludes,
261                // We need to handle the possibility of the dotrc not specifying any excludes,
262                // as well as converting from the raw `String` input to a `PathBuf`
263                dotrc_config
264                    .excludes
265                    .unwrap_or_else(|| vec![])
266                    .iter()
267                    .map(PathBuf::from)
268                    .collect(),
269            )
270            .into_iter()
271            // Try to glob expand each exclude
272            .map(|path| expand_glob(&path, &dotfiles_path))
273            // If any glob expansion failed due to an I/O error, give up
274            .collect::<Result<Vec<Vec<_>>, _>>()?
275            // Then flatten the glob-expanded results
276            .into_iter()
277            .flatten()
278            // Finally, make each exclude path absolute by prepending them with
279            // the dotfiles path
280            .map(|exclude| AbsolutePath::from(dotfiles_path.join(exclude)))
281            .collect();
282
283        // Finally, remove any duplicate entries due to files matching multiple globs
284        let set: HashSet<_> = excludes.drain(..).collect();
285        excludes.extend(set.into_iter());
286
287        excludes
288    };
289
290    let tags = util::append_vecs(
291        partial_config.tags,
292        dotrc_config.tags.unwrap_or_else(|| vec![]),
293    );
294
295    let hostname = merge_hierarchy(partial_config.hostname, dotrc_config.hostname);
296
297    let platform = match (partial_config.platform, dotrc_config.platform) {
298        ((platform, PartialSource::Cli), _) => platform,
299        (_, Some(platform)) => Platform::from_str(&platform)?,
300        ((platform, PartialSource::Default), None) => platform,
301    };
302
303    let command = partial_config.command;
304
305    Ok(Config {
306        excludes,
307        tags,
308        dotfiles_path,
309        hostname,
310        platform,
311        command,
312    })
313}
314
315/// Given the partial config built from CLI arguments and default values, tries
316/// to find the dotrc file.
317///
318/// Searches the following locations, in order:
319/// - The `host-` folder matching the hostname in `partial_config`
320/// - Any `tag-` folders matching the tags in `partial_config` (the tags are
321///   searched in an unspecified order)
322/// - The default location (`~/.dotrc`)
323fn find_dotrc(partial_config: &PartialConfig) -> Option<AbsolutePath> {
324    let config = partial_config.to_config().ok()?;
325
326    // Try to check if a dotrc was among the files discovered from partial_config
327    let items = crate::resolver::get(&config).ok()?;
328    for item in items {
329        match item.dest().file_name() {
330            Some(name) if DOTRC_NAMES.contains(&name) => {
331                verbose_println!("Discovered dotrc at {}", item.source());
332                return Some(item.source().clone());
333            },
334            _ => (),
335        }
336    }
337
338    // Otherwise, try to find a dotrc in the home directory
339    for dotrc_name in DOTRC_NAMES.iter() {
340        let dotrc_path = util::home_dir().join(dotrc_name);
341        if dotrc_path.exists() {
342            return Some(AbsolutePath::from(dotrc_path));
343        }
344    }
345
346    None
347}
348
349/// Loads the configuration.
350///
351/// Draws from CLI arguments, the dotrc, and default values (where applicable)
352pub fn get() -> Result<Config, Error> {
353    let partial_config = PartialConfig::merge(cli::Config::get(), DefaultConfig::get()?);
354    let dotrc_config = dotrc::get(find_dotrc(&partial_config))?;
355    let config = merge_dotrc(partial_config, dotrc_config)?;
356
357    Ok(config)
358}
359
360#[derive(Fail, Debug, From)]
361pub enum Error {
362    #[fail(display = "error reading system hostname")]
363    NoSystemHostname,
364
365    #[fail(display = "error reading file or directory ({})", _0)]
366    WalkdirError(#[fail(cause)] walkdir::Error),
367
368    #[fail(display = "{}", _0)]
369    DotrcError(#[fail(cause)] dotrc::Error),
370
371    #[fail(display = "{}", _0)]
372    InvalidPlatform(#[fail(cause)] common::PlatformParseError),
373}
374use Error::*;