1use serde::{Deserialize, Serialize};
2use std::collections::HashMap;
3use std::path::{Path, PathBuf};
4
5pub const DEFAULT_CONFIG_TOML: &str = include_str!("assets/config.toml");
7
8#[derive(Debug, thiserror::Error)]
10#[non_exhaustive]
11pub enum ConfigError {
12 #[error("failed to read config file '{}'", path.display())]
14 Io {
15 path: PathBuf,
16 #[source]
17 source: std::io::Error,
18 },
19 #[error("failed to parse config file '{}'", path.display())]
21 Parse {
22 path: PathBuf,
23 #[source]
24 source: toml::de::Error,
25 },
26}
27
28const CONFIG_FILENAMES: &[&str] = &["flake-edit.toml", ".flake-edit.toml"];
30
31#[derive(Debug, Clone, Serialize, Deserialize, Default)]
33#[serde(deny_unknown_fields)]
34pub struct Config {
35 #[serde(default)]
36 pub follow: FollowConfig,
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize)]
41#[serde(deny_unknown_fields)]
42pub struct FollowConfig {
43 #[serde(default)]
47 pub ignore: Vec<String>,
48
49 #[serde(default = "default_transitive_min")]
53 pub transitive_min: usize,
54
55 #[serde(default = "default_follow_aliases")]
58 pub aliases: HashMap<String, Vec<String>>,
59
60 #[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 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 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 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 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 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 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 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 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 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 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 assert!(config.is_ignored("crane.systems", "systems"));
314 assert!(config.is_ignored("poetry2nix.systems", "systems"));
315
316 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 assert!(config.can_follow("nixpkgs-lib", "nixpkgs"));
353 assert!(config.can_follow("nixpkgs", "nixpkgs"));
355 assert!(!config.can_follow("nixpkgs", "nixpkgs-lib"));
357 }
358}