Skip to main content

flake_edit/app/
handler.rs

1use std::collections::BTreeMap;
2use std::path::PathBuf;
3
4use crate::cli::{CliArgs, Command, ListFormat};
5use crate::config::ConfigError;
6use crate::edit::{InputMap, sorted_input_ids};
7use crate::input::Follows;
8use crate::tui;
9
10use super::commands::{self, CommandError};
11use super::editor::Editor;
12use super::state::AppState;
13
14mod root;
15
16pub type Result<T> = std::result::Result<T, HandlerError>;
17
18#[derive(Debug, thiserror::Error)]
19pub enum HandlerError {
20    #[error(transparent)]
21    Command(#[from] CommandError),
22
23    #[error(transparent)]
24    Io(#[from] std::io::Error),
25
26    #[error(transparent)]
27    FlakeEdit(#[from] crate::error::FlakeEditError),
28
29    #[error(transparent)]
30    Config(#[from] ConfigError),
31
32    #[error("Flake not found")]
33    FlakeNotFound,
34
35    #[error("--flake and --lock cannot be used with 'follow [PATHS]'")]
36    IncompatibleFollowOptions,
37}
38
39/// Main entry point for the application.
40///
41/// Parses CLI arguments, initializes state, and dispatches to command handlers.
42pub fn run(args: CliArgs) -> Result<()> {
43    // Handle batch mode for follow [paths...] early, before creating Editor/AppState
44    if let Command::Follow { paths, transitive } = args.subcommand()
45        && !paths.is_empty()
46    {
47        if args.flake().is_some() || args.lock_file().is_some() {
48            return Err(HandlerError::IncompatibleFollowOptions);
49        }
50        return commands::follow_auto_batch(paths, *transitive, &args)
51            .map_err(HandlerError::Command);
52    }
53
54    // Find flake.nix path
55    let flake_path = if let Some(flake) = args.flake() {
56        PathBuf::from(flake)
57    } else {
58        let path = PathBuf::from("flake.nix");
59        let binding = root::Root::from_path(path).map_err(|_| HandlerError::FlakeNotFound)?;
60        binding.path().to_path_buf()
61    };
62
63    // Create editor and state
64    let editor = Editor::from_path(flake_path.clone())?;
65    let mut flake_edit = editor.create_flake_edit()?;
66    let interactive = tui::is_interactive(args.non_interactive());
67
68    let no_cache = args.no_cache();
69    let mut state = AppState::new(editor.text(), flake_path, args.config().map(PathBuf::from))?
70        .with_diff(args.diff())
71        .with_no_lock(args.no_lock())
72        .with_interactive(interactive)
73        .with_lock_file(args.lock_file().map(PathBuf::from))
74        .with_no_cache(no_cache)
75        .with_cache_path(args.cache().map(PathBuf::from));
76
77    // Dispatch to command
78    match args.subcommand() {
79        Command::Add {
80            uri,
81            ref_or_rev,
82            id,
83            no_flake,
84            shallow,
85        } => {
86            commands::add(
87                &editor,
88                &mut flake_edit,
89                &state,
90                id.clone(),
91                uri.clone(),
92                commands::UriOptions {
93                    ref_or_rev: ref_or_rev.as_deref(),
94                    shallow: *shallow,
95                    no_flake: *no_flake,
96                },
97            )?;
98        }
99
100        Command::Remove { id } => {
101            commands::remove(&editor, &mut flake_edit, &state, id.clone())?;
102        }
103
104        Command::Change {
105            uri,
106            ref_or_rev,
107            id,
108            shallow,
109        } => {
110            commands::change(
111                &editor,
112                &mut flake_edit,
113                &state,
114                id.clone(),
115                uri.clone(),
116                ref_or_rev.as_deref(),
117                *shallow,
118            )?;
119        }
120
121        Command::List { format } => {
122            commands::list(&mut flake_edit, format)?;
123        }
124
125        Command::Update { id, init } => {
126            commands::update(&editor, &mut flake_edit, &state, id.clone(), *init)?;
127        }
128
129        Command::Pin { id, rev } => {
130            commands::pin(&editor, &mut flake_edit, &state, id.clone(), rev.clone())?;
131        }
132
133        Command::Unpin { id } => {
134            commands::unpin(&editor, &mut flake_edit, &state, id.clone())?;
135        }
136
137        Command::Follow {
138            paths: _,
139            transitive,
140        } => {
141            // Batch mode with paths is handled above; here we run on the current flake
142            if let Some(min) = transitive {
143                state.config.follow.transitive_min = *min;
144            }
145            commands::follow_auto(&editor, &mut flake_edit, &state)?;
146        }
147
148        Command::AddFollow { input, target } => {
149            commands::add_follow(
150                &editor,
151                &mut flake_edit,
152                &state,
153                input.clone(),
154                target.clone(),
155            )?;
156        }
157
158        Command::Completion { inputs: _, mode } => {
159            use crate::cache::{Cache, DEFAULT_URI_TYPES};
160            use crate::cli::CompletionMode;
161            match mode {
162                CompletionMode::Add => {
163                    for uri_type in DEFAULT_URI_TYPES {
164                        println!("{}", uri_type);
165                    }
166                    let cache = Cache::load();
167                    for uri in cache.list_uris() {
168                        println!("{}", uri);
169                    }
170                    std::process::exit(0);
171                }
172                CompletionMode::Change => {
173                    let inputs = flake_edit.list();
174                    // Cache inputs while we have them
175                    crate::cache::populate_cache_from_input_map(inputs, no_cache);
176                    for id in inputs.keys() {
177                        println!("{}", id);
178                    }
179                    std::process::exit(0);
180                }
181                CompletionMode::Follow => {
182                    // Get nested input paths from lockfile for follow completions
183                    if let Ok(lock) = crate::lock::FlakeLock::from_default_path() {
184                        for path in lock.nested_input_paths() {
185                            println!("{}", path);
186                        }
187                    }
188                    std::process::exit(0);
189                }
190                CompletionMode::None => {}
191            }
192        }
193
194        Command::Config {
195            print_default,
196            path,
197        } => {
198            commands::config(*print_default, *path)?;
199            return Ok(());
200        }
201    }
202
203    // Cache any inputs we've seen during this command.
204    // This helps build up the completion cache over time as users interact
205    // with different flakes, not just when they explicitly add inputs.
206    crate::cache::populate_cache_from_input_map(flake_edit.curr_list(), no_cache);
207
208    Ok(())
209}
210
211/// List inputs in the specified format.
212pub fn list_inputs(inputs: &InputMap, format: &ListFormat) {
213    match format {
214        ListFormat::Simple => list_simple(inputs),
215        ListFormat::Json => list_json(inputs),
216        ListFormat::Detailed => list_detailed(inputs),
217        ListFormat::Raw => list_raw(inputs),
218        ListFormat::Toplevel => list_toplevel(inputs),
219        ListFormat::None => unreachable!("Should not be possible"),
220    }
221}
222
223fn list_simple(inputs: &InputMap) {
224    let mut buf = String::new();
225    for key in sorted_input_ids(inputs) {
226        let input = &inputs[key];
227        if !buf.is_empty() {
228            buf.push('\n');
229        }
230        buf.push_str(input.bare_id());
231        for follows in input.follows() {
232            if let Follows::Indirect(id, _) = follows {
233                let id = format!("{}.{}", input.bare_id(), id);
234                if !buf.is_empty() {
235                    buf.push('\n');
236                }
237                buf.push_str(&id);
238            }
239        }
240    }
241    println!("{buf}");
242}
243
244fn list_json(inputs: &InputMap) {
245    let sorted: BTreeMap<_, _> = inputs.iter().collect();
246    let json = serde_json::to_string(&sorted).unwrap();
247    println!("{json}");
248}
249
250fn list_toplevel(inputs: &InputMap) {
251    let mut buf = String::new();
252    for key in sorted_input_ids(inputs) {
253        if !buf.is_empty() {
254            buf.push('\n');
255        }
256        buf.push_str(&key.to_string());
257    }
258    println!("{buf}");
259}
260
261fn list_raw(inputs: &InputMap) {
262    let sorted: BTreeMap<_, _> = inputs.iter().collect();
263    println!("{:#?}", sorted);
264}
265
266/// Check if a URL is a top-level follows reference (e.g., "harmonia/treefmt-nix")
267/// rather than a real URL (which would have a protocol like "github:" or "git+").
268fn is_toplevel_follows(url: &str) -> bool {
269    let url_trimmed = url.trim_matches('"');
270    !url_trimmed.is_empty()
271        && !url_trimmed.contains(':')
272        && url_trimmed.contains('/')
273        && !url_trimmed.starts_with('/')
274}
275
276fn list_detailed(inputs: &InputMap) {
277    let mut buf = String::new();
278    for key in sorted_input_ids(inputs) {
279        let input = &inputs[key];
280        if !buf.is_empty() {
281            buf.push('\n');
282        }
283        let line = if is_toplevel_follows(input.url()) {
284            format!("· {} <= {}", input.id(), input.url())
285        } else {
286            format!("· {} - {}", input.id(), input.url())
287        };
288        buf.push_str(&line);
289        for follows in input.follows() {
290            if let Follows::Indirect(id, follow_id) = follows {
291                let id = format!("{}{} => {}", " ".repeat(5), id, follow_id);
292                if !buf.is_empty() {
293                    buf.push('\n');
294                }
295                buf.push_str(&id);
296            }
297        }
298    }
299    println!("{buf}");
300}
301
302#[cfg(test)]
303mod tests {
304    use super::*;
305
306    #[test]
307    fn test_is_toplevel_follows() {
308        // Positive: follows references look like "parent/nested"
309        assert!(is_toplevel_follows("\"harmonia/treefmt-nix\""));
310        assert!(is_toplevel_follows("\"clan-core/treefmt-nix\""));
311        assert!(is_toplevel_follows("clan-core/systems"));
312
313        // Negative: real URLs have protocols
314        assert!(!is_toplevel_follows("\"github:NixOS/nixpkgs\""));
315        assert!(!is_toplevel_follows(
316            "\"git+https://git.clan.lol/clan/clan-core\""
317        ));
318        assert!(!is_toplevel_follows("\"path:/some/local/path\""));
319        assert!(!is_toplevel_follows("\"https://github.com/pinpox.keys\""));
320
321        // Negative: absolute paths
322        assert!(!is_toplevel_follows("\"/nix/store/abc\""));
323
324        // Negative: no slash (just a name)
325        assert!(!is_toplevel_follows("\"nixpkgs\""));
326
327        // Negative: empty
328        assert!(!is_toplevel_follows(""));
329        assert!(!is_toplevel_follows("\"\""));
330    }
331}