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