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, Default)]
37#[serde(deny_unknown_fields)]
38pub struct FollowConfig {
39 #[serde(default)]
41 pub ignore: Vec<String>,
42
43 #[serde(default)]
46 pub aliases: HashMap<String, Vec<String>>,
47}
48
49impl FollowConfig {
50 pub fn is_ignored(&self, path: &str, name: &str) -> bool {
56 self.ignore.iter().any(|ignored| {
57 if ignored.contains('.') {
59 ignored == path
60 } else {
61 ignored == name
63 }
64 })
65 }
66
67 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 pub fn can_follow(&self, nested_name: &str, top_level_name: &str) -> bool {
81 if nested_name == top_level_name {
83 return true;
84 }
85 self.resolve_alias(nested_name) == Some(top_level_name)
87 }
88}
89
90impl Config {
91 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 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 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 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 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 assert!(config.is_ignored("crane.systems", "systems"));
212 assert!(config.is_ignored("poetry2nix.systems", "systems"));
213
214 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 assert!(config.can_follow("nixpkgs-lib", "nixpkgs"));
251 assert!(config.can_follow("nixpkgs", "nixpkgs"));
253 assert!(!config.can_follow("nixpkgs", "nixpkgs-lib"));
255 }
256}