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 embedded in the binary.
6pub const DEFAULT_CONFIG_TOML: &str = include_str!("assets/config.toml");
7
8/// Error type for configuration loading failures.
9#[derive(Debug, thiserror::Error)]
10pub enum ConfigError {
11    #[error("Failed to read config file '{path}': {source}")]
12    Io {
13        path: PathBuf,
14        #[source]
15        source: std::io::Error,
16    },
17    #[error("Failed to parse config file '{path}':\n{source}")]
18    Parse {
19        path: PathBuf,
20        #[source]
21        source: toml::de::Error,
22    },
23}
24
25/// Filenames to search for project-level configuration.
26const CONFIG_FILENAMES: &[&str] = &["flake-edit.toml", ".flake-edit.toml"];
27
28#[derive(Debug, Clone, Serialize, Deserialize, Default)]
29#[serde(deny_unknown_fields)]
30pub struct Config {
31    #[serde(default)]
32    pub follow: FollowConfig,
33}
34
35/// Configuration for the `follow` command.
36#[derive(Debug, Clone, Serialize, Deserialize)]
37#[serde(deny_unknown_fields)]
38pub struct FollowConfig {
39    /// Inputs to ignore during follow.
40    #[serde(default)]
41    pub ignore: Vec<String>,
42
43    /// Minimum number of transitive follows needed to add a top-level follows input.
44    /// Set to 0 to disable transitive follows deduplication.
45    #[serde(default = "default_transitive_min")]
46    pub transitive_min: usize,
47
48    /// Alias mappings: canonical_name -> [alternative_names]
49    /// e.g., nixpkgs = ["nixpkgs-lib"] means nixpkgs-lib can follow nixpkgs
50    #[serde(default)]
51    pub aliases: HashMap<String, Vec<String>>,
52}
53
54impl Default for FollowConfig {
55    fn default() -> Self {
56        Self {
57            ignore: Vec::new(),
58            transitive_min: default_transitive_min(),
59            aliases: HashMap::new(),
60        }
61    }
62}
63
64impl FollowConfig {
65    /// Check if an input should be ignored.
66    ///
67    /// Supports two formats:
68    /// - Full path: `"crane.nixpkgs"` - ignores only that specific nested input
69    /// - Simple name: `"nixpkgs"` - ignores all nested inputs with that name
70    pub fn is_ignored(&self, path: &str, name: &str) -> bool {
71        self.ignore.iter().any(|ignored| {
72            // Check for full path match first (more specific)
73            if ignored.contains('.') {
74                ignored == path
75            } else {
76                // Simple name match
77                ignored == name
78            }
79        })
80    }
81
82    /// Find the canonical name for a given input name.
83    /// Returns the canonical name if found in aliases, otherwise returns None.
84    pub fn resolve_alias(&self, name: &str) -> Option<&str> {
85        for (canonical, alternatives) in &self.aliases {
86            if alternatives.iter().any(|alt| alt == name) {
87                return Some(canonical);
88            }
89        }
90        None
91    }
92
93    /// Check if `nested_name` can follow `top_level_name`.
94    /// Returns true if they match directly or via alias.
95    pub fn can_follow(&self, nested_name: &str, top_level_name: &str) -> bool {
96        // Direct match
97        if nested_name == top_level_name {
98            return true;
99        }
100        // Check if nested_name is an alias for top_level_name
101        self.resolve_alias(nested_name) == Some(top_level_name)
102    }
103
104    pub fn transitive_min(&self) -> usize {
105        self.transitive_min
106    }
107}
108
109impl Config {
110    /// Load configuration in the following order:
111    /// 1. Project-level config (flake-edit.toml or .flake-edit.toml in current/parent dirs)
112    /// 2. User-level config (~/.config/flake-edit/config.toml)
113    /// 3. Default embedded config
114    ///
115    /// Returns an error if a config file exists but is malformed.
116    pub fn load() -> Result<Self, ConfigError> {
117        if let Some(path) = Self::project_config_path() {
118            return Self::try_load_from_file(&path);
119        }
120        if let Some(path) = Self::user_config_path() {
121            return Self::try_load_from_file(&path);
122        }
123        Ok(Self::default())
124    }
125
126    /// Load configuration from an explicitly specified path.
127    ///
128    /// Returns an error if the file doesn't exist or is malformed.
129    /// If no path is specified, falls back to the default load order.
130    pub fn load_from(path: Option<&Path>) -> Result<Self, ConfigError> {
131        match path {
132            Some(p) => Self::try_load_from_file(p),
133            None => Self::load(),
134        }
135    }
136
137    /// Try to load config from a file, returning detailed errors on failure.
138    fn try_load_from_file(path: &Path) -> Result<Self, ConfigError> {
139        let content = std::fs::read_to_string(path).map_err(|e| ConfigError::Io {
140            path: path.to_path_buf(),
141            source: e,
142        })?;
143        toml::from_str(&content).map_err(|e| ConfigError::Parse {
144            path: path.to_path_buf(),
145            source: e,
146        })
147    }
148
149    pub fn project_config_path() -> Option<PathBuf> {
150        let cwd = std::env::current_dir().ok()?;
151        Self::find_config_in_ancestors(&cwd)
152    }
153
154    fn xdg_config_dir() -> Option<PathBuf> {
155        let dirs = directories::ProjectDirs::from("", "", "flake-edit")?;
156        Some(dirs.config_dir().to_path_buf())
157    }
158
159    pub fn user_config_path() -> Option<PathBuf> {
160        let config_path = Self::xdg_config_dir()?.join("config.toml");
161        config_path.exists().then_some(config_path)
162    }
163
164    pub fn user_config_dir() -> Option<PathBuf> {
165        Self::xdg_config_dir()
166    }
167
168    fn find_config_in_ancestors(start: &Path) -> Option<PathBuf> {
169        let mut current = start.to_path_buf();
170        loop {
171            for filename in CONFIG_FILENAMES {
172                let config_path = current.join(filename);
173                if config_path.exists() {
174                    return Some(config_path);
175                }
176            }
177            if !current.pop() {
178                break;
179            }
180        }
181        None
182    }
183}
184
185#[cfg(test)]
186mod tests {
187    use super::*;
188
189    #[test]
190    fn test_default_config_parses() {
191        let config: Config =
192            toml::from_str(DEFAULT_CONFIG_TOML).expect("default config should parse");
193        assert!(config.follow.ignore.is_empty());
194        assert_eq!(config.follow.transitive_min, 0);
195        assert!(config.follow.aliases.is_empty());
196    }
197
198    #[test]
199    fn test_is_ignored_by_name() {
200        let config = FollowConfig {
201            ignore: vec!["flake-utils".to_string(), "systems".to_string()],
202            ..Default::default()
203        };
204
205        // Simple name matching ignores all inputs with that name
206        assert!(config.is_ignored("crane.flake-utils", "flake-utils"));
207        assert!(config.is_ignored("poetry2nix.systems", "systems"));
208        assert!(!config.is_ignored("crane.nixpkgs", "nixpkgs"));
209    }
210
211    #[test]
212    fn test_is_ignored_by_path() {
213        let config = FollowConfig {
214            ignore: vec!["crane.nixpkgs".to_string()],
215            ..Default::default()
216        };
217
218        // Full path matching only ignores that specific input
219        assert!(config.is_ignored("crane.nixpkgs", "nixpkgs"));
220        assert!(!config.is_ignored("poetry2nix.nixpkgs", "nixpkgs"));
221    }
222
223    #[test]
224    fn test_is_ignored_mixed() {
225        let config = FollowConfig {
226            ignore: vec!["systems".to_string(), "crane.flake-utils".to_string()],
227            ..Default::default()
228        };
229
230        // "systems" ignored everywhere
231        assert!(config.is_ignored("crane.systems", "systems"));
232        assert!(config.is_ignored("poetry2nix.systems", "systems"));
233
234        // "flake-utils" only ignored for crane
235        assert!(config.is_ignored("crane.flake-utils", "flake-utils"));
236        assert!(!config.is_ignored("poetry2nix.flake-utils", "flake-utils"));
237    }
238
239    #[test]
240    fn test_resolve_alias() {
241        let config = FollowConfig {
242            aliases: HashMap::from([(
243                "nixpkgs".to_string(),
244                vec!["nixpkgs-lib".to_string(), "nixpkgs-stable".to_string()],
245            )]),
246            ..Default::default()
247        };
248
249        assert_eq!(config.resolve_alias("nixpkgs-lib"), Some("nixpkgs"));
250        assert_eq!(config.resolve_alias("nixpkgs-stable"), Some("nixpkgs"));
251        assert_eq!(config.resolve_alias("nixpkgs"), None);
252        assert_eq!(config.resolve_alias("unknown"), None);
253    }
254
255    #[test]
256    fn test_can_follow_direct_match() {
257        let config = FollowConfig::default();
258        assert!(config.can_follow("nixpkgs", "nixpkgs"));
259        assert!(!config.can_follow("nixpkgs", "flake-utils"));
260    }
261
262    #[test]
263    fn test_can_follow_with_alias() {
264        let config = FollowConfig {
265            aliases: HashMap::from([("nixpkgs".to_string(), vec!["nixpkgs-lib".to_string()])]),
266            ..Default::default()
267        };
268
269        // nixpkgs-lib can follow nixpkgs
270        assert!(config.can_follow("nixpkgs-lib", "nixpkgs"));
271        // direct match still works
272        assert!(config.can_follow("nixpkgs", "nixpkgs"));
273        // but not the reverse
274        assert!(!config.can_follow("nixpkgs", "nixpkgs-lib"));
275    }
276}
277
278fn default_transitive_min() -> usize {
279    0
280}