Skip to main content

flake_edit/
config.rs

1use serde::{Deserialize, Serialize};
2use std::collections::HashMap;
3use std::path::{Path, PathBuf};
4
5/// Default configuration TOML embedded in the binary.
6pub const DEFAULT_CONFIG_TOML: &str = include_str!("assets/config.toml");
7
8/// Configuration loading failures.
9#[derive(Debug, thiserror::Error)]
10#[non_exhaustive]
11pub enum ConfigError {
12    /// Failed to read the configuration file from disk.
13    #[error("failed to read config file '{}'", path.display())]
14    Io {
15        path: PathBuf,
16        #[source]
17        source: std::io::Error,
18    },
19    /// Failed to parse a configuration file as TOML.
20    #[error("failed to parse config file '{}'", path.display())]
21    Parse {
22        path: PathBuf,
23        #[source]
24        source: toml::de::Error,
25    },
26}
27
28/// Filenames searched for project-level configuration, in priority order.
29const CONFIG_FILENAMES: &[&str] = &["flake-edit.toml", ".flake-edit.toml"];
30
31/// Top-level `flake-edit.toml` configuration.
32#[derive(Debug, Clone, Serialize, Deserialize, Default)]
33#[serde(deny_unknown_fields)]
34pub struct Config {
35    #[serde(default)]
36    pub follow: FollowConfig,
37}
38
39/// `[follow]` section of [`Config`].
40#[derive(Debug, Clone, Serialize, Deserialize)]
41#[serde(deny_unknown_fields)]
42pub struct FollowConfig {
43    /// Inputs to skip during follow analysis.
44    ///
45    /// See [`Self::is_ignored`] for the matching rules.
46    #[serde(default)]
47    pub ignore: Vec<String>,
48
49    /// Minimum number of transitive references required before a shared
50    /// nested input is promoted to top-level. `0` disables transitive
51    /// deduplication.
52    #[serde(default = "default_transitive_min")]
53    pub transitive_min: usize,
54
55    /// Alias mappings: canonical name to alternative names. For example,
56    /// `nixpkgs = ["nixpkgs-lib"]` lets `nixpkgs-lib` follow `nixpkgs`.
57    #[serde(default = "default_follow_aliases")]
58    pub aliases: HashMap<String, Vec<String>>,
59
60    /// Maximum depth of follows declarations to write.
61    ///
62    /// `None` (the default) writes follows at every depth the lockfile graph
63    /// supports. `Some(n)` caps the depth: `Some(1)` writes only
64    /// `parent.nested.follows = "target"`, `Some(2)` also writes deeper paths
65    /// such as `parent.middle.grandchild.follows = "target"`.
66    #[serde(default)]
67    pub max_depth: Option<usize>,
68}
69
70impl Default for FollowConfig {
71    fn default() -> Self {
72        Self {
73            ignore: Vec::new(),
74            transitive_min: default_transitive_min(),
75            aliases: default_follow_aliases(),
76            max_depth: None,
77        }
78    }
79}
80
81impl FollowConfig {
82    /// True if the input at `path` (e.g. `crane.nixpkgs`) with simple `name`
83    /// (e.g. `nixpkgs`) is in [`Self::ignore`].
84    ///
85    /// Entries containing a `.` match the full dotted path. Bare entries
86    /// match by name across all parents.
87    pub fn is_ignored(&self, path: &str, name: &str) -> bool {
88        self.ignore.iter().any(|ignored| {
89            if ignored.contains('.') {
90                ignored == path
91            } else {
92                ignored == name
93            }
94        })
95    }
96
97    /// Canonical name `name` is an alias of, or `None` if no alias applies.
98    pub fn resolve_alias(&self, name: &str) -> Option<&str> {
99        for (canonical, alternatives) in &self.aliases {
100            if alternatives.iter().any(|alt| alt == name) {
101                return Some(canonical);
102            }
103        }
104        None
105    }
106
107    /// True if `nested_name` may follow `top_level_name` (direct match or via
108    /// [`Self::aliases`]).
109    pub fn can_follow(&self, nested_name: &str, top_level_name: &str) -> bool {
110        if nested_name == top_level_name {
111            return true;
112        }
113        self.resolve_alias(nested_name) == Some(top_level_name)
114    }
115
116    pub fn transitive_min(&self) -> usize {
117        self.transitive_min
118    }
119}
120
121impl Config {
122    /// Load the first available configuration:
123    /// 1. Project-level ([`CONFIG_FILENAMES`], walking upward from the
124    ///    current directory).
125    /// 2. User-level (`~/.config/flake-edit/config.toml`).
126    /// 3. The default embedded config.
127    ///
128    /// # Errors
129    ///
130    /// Returns [`ConfigError`] if a discovered file cannot be read or
131    /// parsed.
132    pub fn load() -> Result<Self, ConfigError> {
133        if let Some(path) = Self::project_config_path() {
134            return Self::try_load_from_file(&path);
135        }
136        if let Some(path) = Self::user_config_path() {
137            return Self::try_load_from_file(&path);
138        }
139        Ok(Self::default())
140    }
141
142    /// Load configuration from `path`, or fall back to [`Self::load`] when
143    /// `path` is `None`.
144    ///
145    /// # Errors
146    ///
147    /// Returns [`ConfigError`] if `path` does not exist or cannot be parsed.
148    pub fn load_from(path: Option<&Path>) -> Result<Self, ConfigError> {
149        match path {
150            Some(p) => Self::try_load_from_file(p),
151            None => Self::load(),
152        }
153    }
154
155    fn try_load_from_file(path: &Path) -> Result<Self, ConfigError> {
156        let content = std::fs::read_to_string(path).map_err(|e| ConfigError::Io {
157            path: path.to_path_buf(),
158            source: e,
159        })?;
160        toml::from_str(&content).map_err(|e| ConfigError::Parse {
161            path: path.to_path_buf(),
162            source: e,
163        })
164    }
165
166    /// Path to the nearest project-level config file, walking upward from
167    /// the current directory.
168    pub fn project_config_path() -> Option<PathBuf> {
169        let cwd = std::env::current_dir().ok()?;
170        Self::find_config_in_ancestors(&cwd)
171    }
172
173    fn xdg_config_dir() -> Option<PathBuf> {
174        let dirs = directories::ProjectDirs::from("", "", "flake-edit")?;
175        Some(dirs.config_dir().to_path_buf())
176    }
177
178    /// Path to `~/.config/flake-edit/config.toml`, or `None` if it does not
179    /// exist.
180    pub fn user_config_path() -> Option<PathBuf> {
181        let config_path = Self::xdg_config_dir()?.join("config.toml");
182        config_path.exists().then_some(config_path)
183    }
184
185    /// XDG config directory for flake-edit, regardless of whether it exists.
186    pub fn user_config_dir() -> Option<PathBuf> {
187        Self::xdg_config_dir()
188    }
189
190    fn find_config_in_ancestors(start: &Path) -> Option<PathBuf> {
191        let mut current = start.to_path_buf();
192        loop {
193            for filename in CONFIG_FILENAMES {
194                let config_path = current.join(filename);
195                if config_path.exists() {
196                    return Some(config_path);
197                }
198            }
199            if !current.pop() {
200                break;
201            }
202        }
203        None
204    }
205}
206
207fn default_transitive_min() -> usize {
208    0
209}
210
211fn default_follow_aliases() -> HashMap<String, Vec<String>> {
212    HashMap::from([("nixpkgs".to_string(), vec!["nixpkgs-lib".to_string()])])
213}
214
215#[cfg(test)]
216mod tests {
217    use super::*;
218
219    #[test]
220    fn test_default_config_parses() {
221        let config: Config =
222            toml::from_str(DEFAULT_CONFIG_TOML).expect("default config should parse");
223        assert!(config.follow.ignore.is_empty());
224        assert_eq!(config.follow.transitive_min, 0);
225        assert_eq!(
226            config.follow.aliases,
227            HashMap::from([("nixpkgs".to_string(), vec!["nixpkgs-lib".to_string()])])
228        );
229        assert_eq!(config.follow.max_depth, None);
230    }
231
232    #[test]
233    fn rust_defaults_match_embedded_default_config() {
234        let embedded: Config =
235            toml::from_str(DEFAULT_CONFIG_TOML).expect("default config should parse");
236        let rust_default = Config::default();
237
238        assert_eq!(embedded.follow.ignore, rust_default.follow.ignore);
239        assert_eq!(
240            embedded.follow.transitive_min,
241            rust_default.follow.transitive_min
242        );
243        assert_eq!(embedded.follow.aliases, rust_default.follow.aliases);
244        assert_eq!(embedded.follow.max_depth, rust_default.follow.max_depth);
245    }
246
247    #[test]
248    fn aliases_default_when_omitted() {
249        let cfg: Config = toml::from_str("[follow]\ntransitive_min = 0\n").unwrap();
250        assert_eq!(
251            cfg.follow.aliases,
252            HashMap::from([("nixpkgs".to_string(), vec!["nixpkgs-lib".to_string()])])
253        );
254    }
255
256    #[test]
257    fn aliases_can_be_cleared() {
258        let cfg: Config = toml::from_str("[follow]\naliases = {}\n").unwrap();
259        assert!(cfg.follow.aliases.is_empty());
260    }
261
262    #[test]
263    fn max_depth_defaults_to_unlimited() {
264        let cfg = FollowConfig::default();
265        assert_eq!(cfg.max_depth, None);
266    }
267
268    #[test]
269    fn max_depth_parses_from_toml() {
270        let cfg: Config = toml::from_str("[follow]\nmax_depth = 2\n").unwrap();
271        assert_eq!(cfg.follow.max_depth, Some(2));
272    }
273
274    #[test]
275    fn max_depth_omitted_means_unlimited() {
276        let cfg: Config = toml::from_str("[follow]\ntransitive_min = 0\n").unwrap();
277        assert_eq!(cfg.follow.max_depth, None);
278    }
279
280    #[test]
281    fn test_is_ignored_by_name() {
282        let config = FollowConfig {
283            ignore: vec!["flake-utils".to_string(), "systems".to_string()],
284            ..Default::default()
285        };
286
287        // Simple name matching ignores all inputs with that name
288        assert!(config.is_ignored("crane.flake-utils", "flake-utils"));
289        assert!(config.is_ignored("poetry2nix.systems", "systems"));
290        assert!(!config.is_ignored("crane.nixpkgs", "nixpkgs"));
291    }
292
293    #[test]
294    fn test_is_ignored_by_path() {
295        let config = FollowConfig {
296            ignore: vec!["crane.nixpkgs".to_string()],
297            ..Default::default()
298        };
299
300        // Full path matching only ignores that specific input
301        assert!(config.is_ignored("crane.nixpkgs", "nixpkgs"));
302        assert!(!config.is_ignored("poetry2nix.nixpkgs", "nixpkgs"));
303    }
304
305    #[test]
306    fn test_is_ignored_mixed() {
307        let config = FollowConfig {
308            ignore: vec!["systems".to_string(), "crane.flake-utils".to_string()],
309            ..Default::default()
310        };
311
312        // "systems" ignored everywhere
313        assert!(config.is_ignored("crane.systems", "systems"));
314        assert!(config.is_ignored("poetry2nix.systems", "systems"));
315
316        // "flake-utils" only ignored for crane
317        assert!(config.is_ignored("crane.flake-utils", "flake-utils"));
318        assert!(!config.is_ignored("poetry2nix.flake-utils", "flake-utils"));
319    }
320
321    #[test]
322    fn test_resolve_alias() {
323        let config = FollowConfig {
324            aliases: HashMap::from([(
325                "nixpkgs".to_string(),
326                vec!["nixpkgs-lib".to_string(), "nixpkgs-stable".to_string()],
327            )]),
328            ..Default::default()
329        };
330
331        assert_eq!(config.resolve_alias("nixpkgs-lib"), Some("nixpkgs"));
332        assert_eq!(config.resolve_alias("nixpkgs-stable"), Some("nixpkgs"));
333        assert_eq!(config.resolve_alias("nixpkgs"), None);
334        assert_eq!(config.resolve_alias("unknown"), None);
335    }
336
337    #[test]
338    fn test_can_follow_direct_match() {
339        let config = FollowConfig::default();
340        assert!(config.can_follow("nixpkgs", "nixpkgs"));
341        assert!(!config.can_follow("nixpkgs", "flake-utils"));
342    }
343
344    #[test]
345    fn test_can_follow_with_alias() {
346        let config = FollowConfig {
347            aliases: HashMap::from([("nixpkgs".to_string(), vec!["nixpkgs-lib".to_string()])]),
348            ..Default::default()
349        };
350
351        // nixpkgs-lib can follow nixpkgs
352        assert!(config.can_follow("nixpkgs-lib", "nixpkgs"));
353        // direct match still works
354        assert!(config.can_follow("nixpkgs", "nixpkgs"));
355        // but not the reverse
356        assert!(!config.can_follow("nixpkgs", "nixpkgs-lib"));
357    }
358}