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)]
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: HashMap::new(),
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
211#[cfg(test)]
212mod tests {
213 use super::*;
214
215 #[test]
216 fn test_default_config_parses() {
217 let config: Config =
218 toml::from_str(DEFAULT_CONFIG_TOML).expect("default config should parse");
219 assert!(config.follow.ignore.is_empty());
220 assert_eq!(config.follow.transitive_min, 0);
221 assert!(config.follow.aliases.is_empty());
222 assert_eq!(config.follow.max_depth, None);
223 }
224
225 #[test]
226 fn max_depth_defaults_to_unlimited() {
227 let cfg = FollowConfig::default();
228 assert_eq!(cfg.max_depth, None);
229 }
230
231 #[test]
232 fn max_depth_parses_from_toml() {
233 let cfg: Config = toml::from_str("[follow]\nmax_depth = 2\n").unwrap();
234 assert_eq!(cfg.follow.max_depth, Some(2));
235 }
236
237 #[test]
238 fn max_depth_omitted_means_unlimited() {
239 let cfg: Config = toml::from_str("[follow]\ntransitive_min = 0\n").unwrap();
240 assert_eq!(cfg.follow.max_depth, None);
241 }
242
243 #[test]
244 fn test_is_ignored_by_name() {
245 let config = FollowConfig {
246 ignore: vec!["flake-utils".to_string(), "systems".to_string()],
247 ..Default::default()
248 };
249
250 assert!(config.is_ignored("crane.flake-utils", "flake-utils"));
252 assert!(config.is_ignored("poetry2nix.systems", "systems"));
253 assert!(!config.is_ignored("crane.nixpkgs", "nixpkgs"));
254 }
255
256 #[test]
257 fn test_is_ignored_by_path() {
258 let config = FollowConfig {
259 ignore: vec!["crane.nixpkgs".to_string()],
260 ..Default::default()
261 };
262
263 assert!(config.is_ignored("crane.nixpkgs", "nixpkgs"));
265 assert!(!config.is_ignored("poetry2nix.nixpkgs", "nixpkgs"));
266 }
267
268 #[test]
269 fn test_is_ignored_mixed() {
270 let config = FollowConfig {
271 ignore: vec!["systems".to_string(), "crane.flake-utils".to_string()],
272 ..Default::default()
273 };
274
275 assert!(config.is_ignored("crane.systems", "systems"));
277 assert!(config.is_ignored("poetry2nix.systems", "systems"));
278
279 assert!(config.is_ignored("crane.flake-utils", "flake-utils"));
281 assert!(!config.is_ignored("poetry2nix.flake-utils", "flake-utils"));
282 }
283
284 #[test]
285 fn test_resolve_alias() {
286 let config = FollowConfig {
287 aliases: HashMap::from([(
288 "nixpkgs".to_string(),
289 vec!["nixpkgs-lib".to_string(), "nixpkgs-stable".to_string()],
290 )]),
291 ..Default::default()
292 };
293
294 assert_eq!(config.resolve_alias("nixpkgs-lib"), Some("nixpkgs"));
295 assert_eq!(config.resolve_alias("nixpkgs-stable"), Some("nixpkgs"));
296 assert_eq!(config.resolve_alias("nixpkgs"), None);
297 assert_eq!(config.resolve_alias("unknown"), None);
298 }
299
300 #[test]
301 fn test_can_follow_direct_match() {
302 let config = FollowConfig::default();
303 assert!(config.can_follow("nixpkgs", "nixpkgs"));
304 assert!(!config.can_follow("nixpkgs", "flake-utils"));
305 }
306
307 #[test]
308 fn test_can_follow_with_alias() {
309 let config = FollowConfig {
310 aliases: HashMap::from([("nixpkgs".to_string(), vec!["nixpkgs-lib".to_string()])]),
311 ..Default::default()
312 };
313
314 assert!(config.can_follow("nixpkgs-lib", "nixpkgs"));
316 assert!(config.can_follow("nixpkgs", "nixpkgs"));
318 assert!(!config.can_follow("nixpkgs", "nixpkgs-lib"));
320 }
321}