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
8const CONFIG_FILENAMES: &[&str] = &["flake-edit.toml", ".flake-edit.toml"];
10
11#[derive(Debug, Clone, Serialize, Deserialize, Default)]
12pub struct Config {
13 #[serde(default)]
14 pub follow: FollowConfig,
15}
16
17#[derive(Debug, Clone, Serialize, Deserialize, Default)]
19pub struct FollowConfig {
20 #[serde(default)]
21 pub auto: FollowAutoConfig,
22}
23
24#[derive(Debug, Clone, Serialize, Deserialize, Default)]
26pub struct FollowAutoConfig {
27 #[serde(default)]
29 pub ignore: Vec<String>,
30
31 #[serde(default)]
34 pub aliases: HashMap<String, Vec<String>>,
35}
36
37impl FollowAutoConfig {
38 pub fn is_ignored(&self, path: &str, name: &str) -> bool {
44 self.ignore.iter().any(|ignored| {
45 if ignored.contains('.') {
47 ignored == path
48 } else {
49 ignored == name
51 }
52 })
53 }
54
55 pub fn resolve_alias(&self, name: &str) -> Option<&str> {
58 for (canonical, alternatives) in &self.aliases {
59 if alternatives.iter().any(|alt| alt == name) {
60 return Some(canonical);
61 }
62 }
63 None
64 }
65
66 pub fn can_follow(&self, nested_name: &str, top_level_name: &str) -> bool {
69 if nested_name == top_level_name {
71 return true;
72 }
73 self.resolve_alias(nested_name) == Some(top_level_name)
75 }
76}
77
78impl Config {
79 pub fn load() -> Self {
84 Self::load_project_config()
85 .or_else(Self::load_user_config)
86 .unwrap_or_default()
87 }
88
89 pub fn project_config_path() -> Option<PathBuf> {
90 let cwd = std::env::current_dir().ok()?;
91 Self::find_config_in_ancestors(&cwd)
92 }
93
94 fn xdg_config_dir() -> Option<PathBuf> {
95 let dirs = directories::ProjectDirs::from("", "", "flake-edit")?;
96 Some(dirs.config_dir().to_path_buf())
97 }
98
99 pub fn user_config_path() -> Option<PathBuf> {
100 let config_path = Self::xdg_config_dir()?.join("config.toml");
101 config_path.exists().then_some(config_path)
102 }
103
104 pub fn user_config_dir() -> Option<PathBuf> {
105 Self::xdg_config_dir()
106 }
107
108 fn load_project_config() -> Option<Self> {
109 let path = Self::project_config_path()?;
110 Self::load_from_file(&path)
111 }
112
113 fn load_user_config() -> Option<Self> {
115 let path = Self::user_config_path()?;
116 Self::load_from_file(&path)
117 }
118
119 fn find_config_in_ancestors(start: &Path) -> Option<PathBuf> {
120 let mut current = start.to_path_buf();
121 loop {
122 for filename in CONFIG_FILENAMES {
123 let config_path = current.join(filename);
124 if config_path.exists() {
125 return Some(config_path);
126 }
127 }
128 if !current.pop() {
129 break;
130 }
131 }
132 None
133 }
134
135 fn load_from_file(path: &Path) -> Option<Self> {
136 let content = std::fs::read_to_string(path).ok()?;
137 match toml::from_str(&content) {
138 Ok(config) => Some(config),
139 Err(e) => {
140 tracing::warn!("Failed to parse config at {}: {}", path.display(), e);
141 None
142 }
143 }
144 }
145}
146
147#[cfg(test)]
148mod tests {
149 use super::*;
150
151 #[test]
152 fn test_default_config_parses() {
153 let config: Config =
154 toml::from_str(DEFAULT_CONFIG_TOML).expect("default config should parse");
155 assert!(config.follow.auto.ignore.is_empty());
156 assert!(config.follow.auto.aliases.is_empty());
157 }
158
159 #[test]
160 fn test_is_ignored_by_name() {
161 let config = FollowAutoConfig {
162 ignore: vec!["flake-utils".to_string(), "systems".to_string()],
163 ..Default::default()
164 };
165
166 assert!(config.is_ignored("crane.flake-utils", "flake-utils"));
168 assert!(config.is_ignored("poetry2nix.systems", "systems"));
169 assert!(!config.is_ignored("crane.nixpkgs", "nixpkgs"));
170 }
171
172 #[test]
173 fn test_is_ignored_by_path() {
174 let config = FollowAutoConfig {
175 ignore: vec!["crane.nixpkgs".to_string()],
176 ..Default::default()
177 };
178
179 assert!(config.is_ignored("crane.nixpkgs", "nixpkgs"));
181 assert!(!config.is_ignored("poetry2nix.nixpkgs", "nixpkgs"));
182 }
183
184 #[test]
185 fn test_is_ignored_mixed() {
186 let config = FollowAutoConfig {
187 ignore: vec!["systems".to_string(), "crane.flake-utils".to_string()],
188 ..Default::default()
189 };
190
191 assert!(config.is_ignored("crane.systems", "systems"));
193 assert!(config.is_ignored("poetry2nix.systems", "systems"));
194
195 assert!(config.is_ignored("crane.flake-utils", "flake-utils"));
197 assert!(!config.is_ignored("poetry2nix.flake-utils", "flake-utils"));
198 }
199
200 #[test]
201 fn test_resolve_alias() {
202 let config = FollowAutoConfig {
203 aliases: HashMap::from([(
204 "nixpkgs".to_string(),
205 vec!["nixpkgs-lib".to_string(), "nixpkgs-stable".to_string()],
206 )]),
207 ..Default::default()
208 };
209
210 assert_eq!(config.resolve_alias("nixpkgs-lib"), Some("nixpkgs"));
211 assert_eq!(config.resolve_alias("nixpkgs-stable"), Some("nixpkgs"));
212 assert_eq!(config.resolve_alias("nixpkgs"), None);
213 assert_eq!(config.resolve_alias("unknown"), None);
214 }
215
216 #[test]
217 fn test_can_follow_direct_match() {
218 let config = FollowAutoConfig::default();
219 assert!(config.can_follow("nixpkgs", "nixpkgs"));
220 assert!(!config.can_follow("nixpkgs", "flake-utils"));
221 }
222
223 #[test]
224 fn test_can_follow_with_alias() {
225 let config = FollowAutoConfig {
226 aliases: HashMap::from([("nixpkgs".to_string(), vec!["nixpkgs-lib".to_string()])]),
227 ..Default::default()
228 };
229
230 assert!(config.can_follow("nixpkgs-lib", "nixpkgs"));
232 assert!(config.can_follow("nixpkgs", "nixpkgs"));
234 assert!(!config.can_follow("nixpkgs", "nixpkgs-lib"));
236 }
237}