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