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)]
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
25const 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#[derive(Debug, Clone, Serialize, Deserialize)]
37#[serde(deny_unknown_fields)]
38pub struct FollowConfig {
39 #[serde(default)]
41 pub ignore: Vec<String>,
42
43 #[serde(default = "default_transitive_min")]
46 pub transitive_min: usize,
47
48 #[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 pub fn is_ignored(&self, path: &str, name: &str) -> bool {
71 self.ignore.iter().any(|ignored| {
72 if ignored.contains('.') {
74 ignored == path
75 } else {
76 ignored == name
78 }
79 })
80 }
81
82 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 pub fn can_follow(&self, nested_name: &str, top_level_name: &str) -> bool {
96 if nested_name == top_level_name {
98 return true;
99 }
100 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 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 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 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 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 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 assert!(config.is_ignored("crane.systems", "systems"));
232 assert!(config.is_ignored("poetry2nix.systems", "systems"));
233
234 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 assert!(config.can_follow("nixpkgs-lib", "nixpkgs"));
271 assert!(config.can_follow("nixpkgs", "nixpkgs"));
273 assert!(!config.can_follow("nixpkgs", "nixpkgs-lib"));
275 }
276}
277
278fn default_transitive_min() -> usize {
279 0
280}