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
171    // Cache any inputs we've seen during this command.
172    // This helps build up the completion cache over time as users interact
173    // with different flakes, not just when they explicitly add inputs.
174    crate::cache::populate_cache_from_input_map(flake_edit.curr_list(), no_cache);
175
176    Ok(())
177}
178
179/// List inputs in the specified format.
180pub fn list_inputs(inputs: &InputMap, format: &ListFormat) {
181    match format {
182        ListFormat::Simple => list_simple(inputs),
183        ListFormat::Json => list_json(inputs),
184        ListFormat::Detailed => list_detailed(inputs),
185        ListFormat::Raw => list_raw(inputs),
186        ListFormat::Toplevel => list_toplevel(inputs),
187        ListFormat::None => unreachable!("Should not be possible"),
188    }
189}
190
191fn list_simple(inputs: &InputMap) {
192    let mut buf = String::new();
193    for key in sorted_input_ids(inputs) {
194        let input = &inputs[key];
195        if !buf.is_empty() {
196            buf.push('\n');
197        }
198        buf.push_str(input.id());
199        for follows in input.follows() {
200            if let Follows::Indirect(id, _) = follows {
201                let id = format!("{}.{}", input.id(), id);
202                if !buf.is_empty() {
203                    buf.push('\n');
204                }
205                buf.push_str(&id);
206            }
207        }
208    }
209    println!("{buf}");
210}
211
212fn list_json(inputs: &InputMap) {
213    let sorted: BTreeMap<_, _> = inputs.iter().collect();
214    let json = serde_json::to_string(&sorted).unwrap();
215    println!("{json}");
216}
217
218fn list_toplevel(inputs: &InputMap) {
219    let mut buf = String::new();
220    for key in sorted_input_ids(inputs) {
221        if !buf.is_empty() {
222            buf.push('\n');
223        }
224        buf.push_str(&key.to_string());
225    }
226    println!("{buf}");
227}
228
229fn list_raw(inputs: &InputMap) {
230    let sorted: BTreeMap<_, _> = inputs.iter().collect();
231    println!("{:#?}", sorted);
232}
233
234/// Check if a URL is a top-level follows reference (e.g., "harmonia/treefmt-nix")
235/// rather than a real URL (which would have a protocol like "github:" or "git+").
236fn is_toplevel_follows(url: &str) -> bool {
237    let url_trimmed = url.trim_matches('"');
238    !url_trimmed.is_empty()
239        && !url_trimmed.contains(':')
240        && url_trimmed.contains('/')
241        && !url_trimmed.starts_with('/')
242}
243
244fn list_detailed(inputs: &InputMap) {
245    let mut buf = String::new();
246    for key in sorted_input_ids(inputs) {
247        let input = &inputs[key];
248        if !buf.is_empty() {
249            buf.push('\n');
250        }
251        let line = if is_toplevel_follows(input.url()) {
252            format!("· {} <= {}", input.id(), input.url())
253        } else {
254            format!("· {} - {}", input.id(), input.url())
255        };
256        buf.push_str(&line);
257        for follows in input.follows() {
258            if let Follows::Indirect(id, follow_id) = follows {
259                let id = format!("{}{} => {}", " ".repeat(5), id, follow_id);
260                if !buf.is_empty() {
261                    buf.push('\n');
262                }
263                buf.push_str(&id);
264            }
265        }
266    }
267    println!("{buf}");
268}
269
270#[cfg(test)]
271mod tests {
272    use super::*;
273
274    #[test]
275    fn test_is_toplevel_follows() {
276        // Positive: follows references look like "parent/nested"
277        assert!(is_toplevel_follows("\"harmonia/treefmt-nix\""));
278        assert!(is_toplevel_follows("\"clan-core/treefmt-nix\""));
279        assert!(is_toplevel_follows("clan-core/systems"));
280
281        // Negative: real URLs have protocols
282        assert!(!is_toplevel_follows("\"github:NixOS/nixpkgs\""));
283        assert!(!is_toplevel_follows(
284            "\"git+https://git.clan.lol/clan/clan-core\""
285        ));
286        assert!(!is_toplevel_follows("\"path:/some/local/path\""));
287        assert!(!is_toplevel_follows("\"https://github.com/pinpox.keys\""));
288
289        // Negative: absolute paths
290        assert!(!is_toplevel_follows("\"/nix/store/abc\""));
291
292        // Negative: no slash (just a name)
293        assert!(!is_toplevel_follows("\"nixpkgs\""));
294
295        // Negative: empty
296        assert!(!is_toplevel_follows(""));
297        assert!(!is_toplevel_follows("\"\""));
298    }
299}