flake_edit/
config.rs

1use serde::{Deserialize, Serialize};
2use std::collections::HashMap;
3use std::path::{Path, PathBuf};
4
5/// Default configuration embedded in the binary.
6pub const DEFAULT_CONFIG_TOML: &str = include_str!("assets/config.toml");
7
8/// Filenames to search for project-level configuration.
9const 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/// Configuration for the `follow` command.
18#[derive(Debug, Clone, Serialize, Deserialize, Default)]
19pub struct FollowConfig {
20    #[serde(default)]
21    pub auto: FollowAutoConfig,
22}
23
24/// Configuration for `follow --auto` behavior.
25#[derive(Debug, Clone, Serialize, Deserialize, Default)]
26pub struct FollowAutoConfig {
27    /// Inputs to ignore during auto-follow.
28    #[serde(default)]
29    pub ignore: Vec<String>,
30
31    /// Alias mappings: canonical_name -> [alternative_names]
32    /// e.g., nixpkgs = ["nixpkgs-lib"] means nixpkgs-lib can follow nixpkgs
33    #[serde(default)]
34    pub aliases: HashMap<String, Vec<String>>,
35}
36
37impl FollowAutoConfig {
38    /// Check if an input should be ignored.
39    ///
40    /// Supports two formats:
41    /// - Full path: `"crane.nixpkgs"` - ignores only that specific nested input
42    /// - Simple name: `"nixpkgs"` - ignores all nested inputs with that name
43    pub fn is_ignored(&self, path: &str, name: &str) -> bool {
44        self.ignore.iter().any(|ignored| {
45            // Check for full path match first (more specific)
46            if ignored.contains('.') {
47                ignored == path
48            } else {
49                // Simple name match
50                ignored == name
51            }
52        })
53    }
54
55    /// Find the canonical name for a given input name.
56    /// Returns the canonical name if found in aliases, otherwise returns None.
57    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    /// Check if `nested_name` can follow `top_level_name`.
67    /// Returns true if they match directly or via alias.
68    pub fn can_follow(&self, nested_name: &str, top_level_name: &str) -> bool {
69        // Direct match
70        if nested_name == top_level_name {
71            return true;
72        }
73        // Check if nested_name is an alias for top_level_name
74        self.resolve_alias(nested_name) == Some(top_level_name)
75    }
76}
77
78impl Config {
79    /// Load configuration in the following order:
80    /// 1. Project-level config (flake-edit.toml or .flake-edit.toml in current/parent dirs)
81    /// 2. User-level config (~/.config/flake-edit/config.toml)
82    /// 3. Default embedded config
83    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    /// Load user-level config from XDG config directory.
114    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        // Simple name matching ignores all inputs with that name
167        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        // Full path matching only ignores that specific input
180        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        // "systems" ignored everywhere
192        assert!(config.is_ignored("crane.systems", "systems"));
193        assert!(config.is_ignored("poetry2nix.systems", "systems"));
194
195        // "flake-utils" only ignored for crane
196        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        // nixpkgs-lib can follow nixpkgs
231        assert!(config.can_follow("nixpkgs-lib", "nixpkgs"));
232        // direct match still works
233        assert!(config.can_follow("nixpkgs", "nixpkgs"));
234        // but not the reverse
235        assert!(!config.can_follow("nixpkgs", "nixpkgs-lib"));
236    }
237}