topiary_config/
source.rs

1//! Configuration for Topiary can be sourced from either that which is built-in, or from disk.
2
3use std::{
4    env::current_dir,
5    ffi::OsString,
6    fmt,
7    io::Cursor,
8    path::{Path, PathBuf},
9};
10
11use crate::error::{TopiaryConfigError, TopiaryConfigResult};
12
13/// Sources of Nickel configuration
14#[derive(Debug, Clone)]
15pub enum Source {
16    Builtin,
17    Directory(PathBuf),
18    File(PathBuf),
19}
20
21impl From<Source> for nickel_lang_core::program::Input<Cursor<String>, OsString> {
22    fn from(source: Source) -> Self {
23        match source {
24            Source::Builtin => {
25                Self::Source(Cursor::new(source.builtin_nickel()), "built-in".into())
26            }
27            Source::Directory(path) => Self::Path(path.into()),
28            Source::File(path) => Self::Path(path.into()),
29        }
30    }
31}
32
33impl Source {
34    /// Iterate through valid sources of configuration, in priority order (highest to lowest):
35    ///
36    /// 1. `path`, passed as a CLI argument/environment variable
37    /// 2. `.topiary/languages.ncl` (or equivalent)
38    /// 3. `~/.config/topiary/languages.ncl`
39    /// 4. OS configuration directory (if different from #3)
40    /// 5. Built-in configuration: [`Self::builtin_nickel`]
41    pub fn config_sources(path: &Option<PathBuf>) -> impl Iterator<Item = (&'static str, Self)> {
42        let mut sources = Vec::new();
43
44        if let Some(path) = path {
45            let source = if path.is_dir() {
46                Self::Directory(path.clone())
47            } else {
48                Self::File(path.clone())
49            };
50
51            sources.push(("CLI", source));
52        }
53
54        sources.append(&mut vec![
55            ("workspace", workspace_config_dir()),
56            #[cfg(target_os = "macos")]
57            ("unix-home", unix_home_config_dir()),
58            ("OS", os_config_dir()),
59            // add built-in config to end
60            ("built-in", Self::Builtin),
61        ]);
62
63        sources.into_iter()
64    }
65
66    /// Return expected query directory associated with the source path
67    pub fn queries_dir(&self) -> Option<PathBuf> {
68        match self {
69            Source::Builtin => None,
70            Source::Directory(dir) => Some(dir.join("queries")),
71            Source::File(file) => file.parent().map(|d| d.join("queries")),
72        }
73    }
74
75    // return a config file if able uses `languages.ncl` for directories
76    pub fn languages_file(&self) -> Option<PathBuf> {
77        match self {
78            Source::Builtin => None,
79            Source::File(file) => Some(file.clone()),
80            Source::Directory(dir) => Some(dir.join("languages.ncl")),
81        }
82    }
83
84    // return an iterator containing all config sources that have been shown to exist
85    fn valid_config_sources(file: &Option<PathBuf>) -> impl Iterator<Item = (&'static str, Self)> {
86        Self::config_sources(file).filter_map(|(hint, candidate)| {
87            if matches!(candidate, Self::Builtin) {
88                return Some((hint, candidate));
89            }
90            let languages_file = candidate.languages_file().unwrap();
91            if !languages_file.exists() {
92                log::debug!("configuration file not found: {}.", candidate);
93                return None;
94            }
95
96            Some((hint, Self::File(languages_file)))
97        })
98    }
99    /// Return all valid configuration sources.
100    /// See [`Self::config_sources`].
101    pub fn fetch_all(file: &Option<PathBuf>) -> Vec<Self> {
102        // We always include the built-in configuration, as a fallback
103        log::info!("Adding built-in configuration to merge");
104        Self::valid_config_sources(file)
105            .inspect(|(hint, candidate)| {
106                let Self::File(path) = candidate else { return };
107                log::info!(
108                    "Adding {hint}-specified configuration to merge: {}",
109                    path.display()
110                );
111            })
112            .map(|(_, s)| s)
113            .collect()
114    }
115
116    /// Checks if a given [`Self`] variant can be found as a path or value
117    pub fn languages_exists(&self) -> bool {
118        match self {
119            Source::Builtin => true,
120            Source::File(file) => file.exists(),
121            Source::Directory(dir) => dir.join("languages.ncl").exists(),
122        }
123    }
124
125    /// Return a valid source of configuration with the highest priority.
126    /// See [`Self::config_sources`].
127    pub fn fetch_one(file: &Option<PathBuf>) -> Self {
128        let (hint, source) = Self::valid_config_sources(file)
129            .next()
130            .expect("built-in should always be present");
131        log::info!("Using {hint}-specified configuration: {source}");
132        source
133    }
134
135    #[allow(clippy::result_large_err)]
136    pub fn read(&self) -> TopiaryConfigResult<Vec<u8>> {
137        match self {
138            Self::Builtin => Ok(self.builtin_nickel().into_bytes()),
139
140            Self::Directory(dir) => read_to_string(&dir.join("languages.ncl")),
141            Self::File(path) => read_to_string(path),
142        }
143    }
144
145    fn builtin_nickel(&self) -> String {
146        include_str!("../languages.ncl").to_string()
147    }
148}
149
150fn read_to_string(path: &Path) -> TopiaryConfigResult<Vec<u8>> {
151    std::fs::read_to_string(path)
152        .map_err(TopiaryConfigError::Io)
153        .map(|s| s.into_bytes())
154}
155
156impl fmt::Display for Source {
157    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
158        match self {
159            Self::Builtin => write!(f, "<built-in>"),
160
161            Self::File(path) | Self::Directory(path) => {
162                // If the configuration is provided through a file, then we know by this point that
163                // it must exist and so the call to `canonicalize` will succeed. However, special
164                // cases -- such as process substitution, which creates a temporary FIFO -- may
165                // fail if the shell has cleaned things up from under us; in which case, we
166                // fallback to the original `path`.
167                let config = path.canonicalize().unwrap_or(path.clone());
168                write!(f, "{}", config.display())
169            }
170        }
171    }
172}
173
174/// Find the OS-specific configuration directory
175/// Directory is not guaranteed to exist.
176fn os_config_dir() -> Source {
177    Source::Directory(crate::project_dirs().config_dir().to_path_buf())
178}
179
180/// Ascend the directory hierarchy, starting from the current working directory, in search of the
181/// nearest `.topiary` configuration directory.
182/// Directory is not guaranteed to exist.
183fn workspace_config_dir() -> Source {
184    let pwd = current_dir().expect("Could not get current working directory");
185    let dir = pwd
186        .ancestors()
187        .map(|path| path.join(".topiary"))
188        .find(|path| path.exists())
189        .unwrap_or_else(|| pwd.join(".topiary"));
190
191    Source::Directory(dir)
192}
193
194/// Certain platforms have alternate config directories (macOS)
195/// polyfill for linux-like `os_config_dir()`
196/// https://docs.rs/directories/latest/src/directories/lib.rs.html#38-43
197/// Directory is not guaranteed to exist.
198#[cfg(target_os = "macos")]
199fn unix_home_config_dir() -> Source {
200    let dir = std::env::home_dir()
201        .unwrap_or_default()
202        .join(".config/topiary");
203
204    Source::Directory(dir)
205}