flake_edit/app/
handler.rs

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