flake_edit/app/
commands.rs

1use nix_uri::urls::UrlWrapper;
2use nix_uri::{FlakeRef, NixUriResult};
3use ropey::Rope;
4
5use crate::change::Change;
6use crate::edit::{FlakeEdit, InputMap, sorted_input_ids, sorted_input_ids_owned};
7use crate::error::FlakeEditError;
8use crate::lock::{FlakeLock, NestedInput};
9use crate::tui;
10use crate::update::Updater;
11use crate::validate;
12
13use super::editor::Editor;
14use super::state::AppState;
15
16fn updater(editor: &Editor, inputs: InputMap) -> Updater {
17    Updater::new(Rope::from_str(&editor.text()), inputs)
18}
19
20pub type Result<T> = std::result::Result<T, CommandError>;
21
22#[derive(Debug, thiserror::Error)]
23pub enum CommandError {
24    #[error(transparent)]
25    FlakeEdit(#[from] FlakeEditError),
26
27    #[error(transparent)]
28    Io(#[from] std::io::Error),
29
30    #[error("No URI provided")]
31    NoUri,
32
33    #[error("No ID provided")]
34    NoId,
35
36    #[error("Could not infer ID from flake reference: {0}")]
37    CouldNotInferId(String),
38
39    #[error("Invalid URI: {0}")]
40    InvalidUri(String),
41
42    #[error("No inputs found in the flake")]
43    NoInputs,
44
45    #[error("Could not read flake.lock")]
46    NoLock,
47
48    #[error("Input not found: {0}")]
49    InputNotFound(String),
50
51    #[error("The input could not be removed: {0}")]
52    CouldNotRemove(String),
53}
54
55/// Load the flake.lock file, using the path from state if provided.
56fn load_flake_lock(state: &AppState) -> std::result::Result<FlakeLock, FlakeEditError> {
57    if let Some(lock_path) = &state.lock_file {
58        FlakeLock::from_file(lock_path)
59    } else {
60        FlakeLock::from_default_path()
61    }
62}
63
64struct FollowContext {
65    nested_inputs: Vec<NestedInput>,
66    top_level_inputs: std::collections::HashSet<String>,
67    /// Full input map for checking URLs (needed for cycle detection)
68    inputs: crate::edit::InputMap,
69}
70
71/// Check if a top-level input's URL is a follows reference to a specific parent input.
72/// For example, `treefmt-nix.follows = "clan-core/treefmt-nix"` has URL `"clan-core/treefmt-nix"`
73/// which is a follows reference to the `clan-core` parent.
74fn is_follows_reference_to_parent(url: &str, parent: &str) -> bool {
75    let url_trimmed = url.trim_matches('"');
76    url_trimmed.starts_with(&format!("{}/", parent))
77}
78
79/// Load nested inputs from lockfile and top-level inputs from flake.nix.
80/// Returns None if no nested inputs found (prints message to stderr).
81fn load_follow_context(
82    flake_edit: &mut FlakeEdit,
83    state: &AppState,
84) -> Result<Option<FollowContext>> {
85    let nested_inputs: Vec<NestedInput> = load_flake_lock(state)
86        .map(|lock| lock.nested_inputs())
87        .unwrap_or_default();
88
89    if nested_inputs.is_empty() {
90        eprintln!("No nested inputs found in flake.lock");
91        eprintln!("Make sure you have run `nix flake lock` first.");
92        return Ok(None);
93    }
94
95    let inputs = flake_edit.list().clone();
96    let top_level_inputs: std::collections::HashSet<String> = inputs.keys().cloned().collect();
97
98    if top_level_inputs.is_empty() {
99        return Err(CommandError::NoInputs);
100    }
101
102    Ok(Some(FollowContext {
103        nested_inputs,
104        top_level_inputs,
105        inputs,
106    }))
107}
108
109/// Result of running the confirm-or-apply workflow.
110enum ConfirmResult {
111    /// Change was applied successfully.
112    Applied,
113    /// User cancelled (Escape or window closed).
114    Cancelled,
115    /// User wants to go back to selection.
116    Back,
117}
118
119/// Run an interactive single-select loop with confirmation.
120///
121/// 1. Show selection screen
122/// 2. User selects an item
123/// 3. Create change based on selection
124/// 4. Show confirmation (with diff if requested)
125/// 5. Apply or go back
126fn interactive_single_select<F, OnApplied, ExtraData>(
127    editor: &Editor,
128    state: &AppState,
129    title: &str,
130    prompt: &str,
131    items: Vec<String>,
132    make_change: F,
133    on_applied: OnApplied,
134) -> Result<()>
135where
136    F: Fn(&str) -> Result<(String, ExtraData)>,
137    OnApplied: Fn(&str, ExtraData),
138{
139    loop {
140        let select_app = tui::App::select_one(title, prompt, items.clone(), state.diff);
141        let Some(tui::AppResult::SingleSelect(result)) = tui::run(select_app)? else {
142            return Ok(());
143        };
144        let tui::SingleSelectResult {
145            item: id,
146            show_diff,
147        } = result;
148        let (change, extra_data) = make_change(&id)?;
149
150        match confirm_or_apply(editor, state, title, &change, show_diff)? {
151            ConfirmResult::Applied => {
152                on_applied(&id, extra_data);
153                break;
154            }
155            ConfirmResult::Back => continue,
156            ConfirmResult::Cancelled => return Ok(()),
157        }
158    }
159    Ok(())
160}
161
162/// Like `interactive_single_select` but for multi-selection.
163fn interactive_multi_select<F>(
164    editor: &Editor,
165    state: &AppState,
166    title: &str,
167    prompt: &str,
168    items: Vec<String>,
169    make_change: F,
170) -> Result<()>
171where
172    F: Fn(&[String]) -> String,
173{
174    loop {
175        let select_app = tui::App::select_many(title, prompt, items.clone(), state.diff);
176        let Some(tui::AppResult::MultiSelect(result)) = tui::run(select_app)? else {
177            return Ok(());
178        };
179        let tui::MultiSelectResultData {
180            items: selected,
181            show_diff,
182        } = result;
183        let change = make_change(&selected);
184
185        match confirm_or_apply(editor, state, title, &change, show_diff)? {
186            ConfirmResult::Applied => break,
187            ConfirmResult::Back => continue,
188            ConfirmResult::Cancelled => return Ok(()),
189        }
190    }
191    Ok(())
192}
193
194/// Run the confirm-or-apply workflow for a change.
195///
196/// If `show_diff` is true, shows a confirmation screen with the diff.
197/// Otherwise applies the change directly.
198///
199/// Returns `Back` if user wants to go back to selection, `Applied` if the
200/// change was applied, or `Cancelled` if the user cancelled.
201fn confirm_or_apply(
202    editor: &Editor,
203    state: &AppState,
204    context: &str,
205    change: &str,
206    show_diff: bool,
207) -> Result<ConfirmResult> {
208    if show_diff || state.diff {
209        let diff = crate::diff::Diff::new(&editor.text(), change).to_string_plain();
210        let confirm_app = tui::App::confirm(context, &diff);
211        let Some(tui::AppResult::Confirm(action)) = tui::run(confirm_app)? else {
212            return Ok(ConfirmResult::Cancelled);
213        };
214        match action {
215            tui::ConfirmResultAction::Apply => {
216                let mut apply_state = state.clone();
217                apply_state.diff = false;
218                editor.apply_or_diff(change, &apply_state)?;
219                Ok(ConfirmResult::Applied)
220            }
221            tui::ConfirmResultAction::Back => Ok(ConfirmResult::Back),
222            tui::ConfirmResultAction::Exit => Ok(ConfirmResult::Cancelled),
223        }
224    } else {
225        editor.apply_or_diff(change, state)?;
226        Ok(ConfirmResult::Applied)
227    }
228}
229
230/// Apply URI options (ref_or_rev, shallow) to a FlakeRef.
231fn apply_uri_options(
232    mut flake_ref: FlakeRef,
233    ref_or_rev: Option<&str>,
234    shallow: bool,
235) -> std::result::Result<FlakeRef, String> {
236    if let Some(ror) = ref_or_rev {
237        flake_ref.r#type.ref_or_rev(Some(ror.to_string())).map_err(|e| {
238            format!(
239                "Cannot apply --ref-or-rev: {}. \
240                The --ref-or-rev option only works with git forge types (github:, gitlab:, sourcehut:) and indirect types (flake:). \
241                For other URI types, use ?ref= or ?rev= query parameters in the URI itself.",
242                e
243            )
244        })?;
245    }
246    if shallow {
247        flake_ref.params.set_shallow(Some("1".to_string()));
248    }
249    Ok(flake_ref)
250}
251
252/// Transform a URI string by applying ref_or_rev and shallow options if specified.
253///
254/// Always validates the URI through nix-uri parsing.
255/// If neither option is set, returns the original URI unchanged after validation.
256/// Otherwise, applies the options and returns the transformed string.
257fn transform_uri(uri: String, ref_or_rev: Option<&str>, shallow: bool) -> Result<String> {
258    let flake_ref: FlakeRef = uri
259        .parse()
260        .map_err(|e| CommandError::InvalidUri(format!("{}: {}", uri, e)))?;
261
262    if ref_or_rev.is_none() && !shallow {
263        return Ok(uri);
264    }
265
266    apply_uri_options(flake_ref, ref_or_rev, shallow)
267        .map(|f| f.to_string())
268        .map_err(CommandError::CouldNotInferId)
269}
270
271#[derive(Default)]
272pub struct UriOptions<'a> {
273    pub ref_or_rev: Option<&'a str>,
274    pub shallow: bool,
275    pub no_flake: bool,
276}
277
278pub fn add(
279    editor: &Editor,
280    flake_edit: &mut FlakeEdit,
281    state: &AppState,
282    id: Option<String>,
283    uri: Option<String>,
284    opts: UriOptions<'_>,
285) -> Result<()> {
286    let change = match (id, uri, state.interactive) {
287        // Both ID and URI provided - non-interactive add
288        (Some(id_val), Some(uri_str), _) => add_with_id_and_uri(id_val, uri_str, &opts)?,
289        // Interactive mode - show TUI (with or without prefill)
290        (id, None, true) | (None, id, true) => {
291            add_interactive(editor, state, id.as_deref(), &opts)?
292        }
293        // Non-interactive with only one arg (could be in id or uri position) - infer ID
294        (Some(uri), None, false) | (None, Some(uri), false) => add_infer_id(uri, &opts)?,
295        // No arguments and non-interactive
296        (None, None, false) => {
297            return Err(CommandError::NoUri);
298        }
299    };
300
301    apply_change(editor, flake_edit, state, change)
302}
303
304fn add_with_id_and_uri(id: String, uri: String, opts: &UriOptions<'_>) -> Result<Change> {
305    let final_uri = transform_uri(uri, opts.ref_or_rev, opts.shallow)?;
306    Ok(Change::Add {
307        id: Some(id),
308        uri: Some(final_uri),
309        flake: !opts.no_flake,
310    })
311}
312
313fn add_interactive(
314    editor: &Editor,
315    state: &AppState,
316    prefill_uri: Option<&str>,
317    opts: &UriOptions<'_>,
318) -> Result<Change> {
319    let tui_app = tui::App::add("Add", editor.text(), prefill_uri, state.cache_config());
320    let Some(tui::AppResult::Change(tui_change)) = tui::run(tui_app)? else {
321        // User cancelled - return a no-op change
322        return Ok(Change::None);
323    };
324
325    // Apply CLI options to the TUI result
326    if let Change::Add { id, uri, flake } = tui_change {
327        let final_uri = uri
328            .map(|u| transform_uri(u, opts.ref_or_rev, opts.shallow))
329            .transpose()?;
330        Ok(Change::Add {
331            id,
332            uri: final_uri,
333            flake: flake && !opts.no_flake,
334        })
335    } else {
336        Ok(tui_change)
337    }
338}
339
340/// Add with only URI provided, inferring ID from the flake reference.
341fn add_infer_id(uri: String, opts: &UriOptions<'_>) -> Result<Change> {
342    let flake_ref: NixUriResult<FlakeRef> = UrlWrapper::convert_or_parse(&uri);
343
344    let (inferred_id, final_uri) = if let Ok(flake_ref) = flake_ref {
345        let flake_ref = apply_uri_options(flake_ref, opts.ref_or_rev, opts.shallow)
346            .map_err(CommandError::CouldNotInferId)?;
347        let parsed_uri = flake_ref.to_string();
348        let final_uri = if parsed_uri.is_empty() || parsed_uri == "none" {
349            uri.clone()
350        } else {
351            parsed_uri
352        };
353        (flake_ref.id(), final_uri)
354    } else {
355        (None, uri.clone())
356    };
357
358    let final_id = inferred_id.ok_or(CommandError::CouldNotInferId(uri))?;
359
360    Ok(Change::Add {
361        id: Some(final_id),
362        uri: Some(final_uri),
363        flake: !opts.no_flake,
364    })
365}
366
367pub fn remove(
368    editor: &Editor,
369    flake_edit: &mut FlakeEdit,
370    state: &AppState,
371    id: Option<String>,
372) -> Result<()> {
373    let change = if let Some(id) = id {
374        Change::Remove {
375            ids: vec![id.into()],
376        }
377    } else if state.interactive {
378        let inputs = flake_edit.list();
379        let mut removable: Vec<String> = Vec::new();
380        for input_id in sorted_input_ids(inputs) {
381            let input = &inputs[input_id];
382            removable.push(input_id.clone());
383            for follows in input.follows() {
384                if let crate::input::Follows::Indirect(from, to) = follows {
385                    removable.push(format!("{}.{} => {}", input_id, from, to));
386                }
387            }
388        }
389        if removable.is_empty() {
390            return Err(CommandError::NoInputs);
391        }
392
393        let tui_app = tui::App::remove("Remove", editor.text(), removable);
394        let Some(tui::AppResult::Change(tui_change)) = tui::run(tui_app)? else {
395            return Ok(());
396        };
397
398        // Strip " => target" suffix for follows entries
399        if let Change::Remove { ids } = tui_change {
400            let stripped_ids: Vec<_> = ids
401                .iter()
402                .map(|id| {
403                    id.to_string()
404                        .split(" => ")
405                        .next()
406                        .unwrap_or(&id.to_string())
407                        .to_string()
408                        .into()
409                })
410                .collect();
411            Change::Remove { ids: stripped_ids }
412        } else {
413            tui_change
414        }
415    } else {
416        return Err(CommandError::NoId);
417    };
418
419    apply_change(editor, flake_edit, state, change)
420}
421
422pub fn change(
423    editor: &Editor,
424    flake_edit: &mut FlakeEdit,
425    state: &AppState,
426    id: Option<String>,
427    uri: Option<String>,
428    ref_or_rev: Option<&str>,
429    shallow: bool,
430) -> Result<()> {
431    let inputs = flake_edit.list();
432
433    let change = match (id, uri, state.interactive) {
434        // Full interactive: select input, then enter URI
435        // Also handles case where only URI provided interactively (need to select input)
436        (None, None, true) | (None, Some(_), true) => {
437            change_full_interactive(editor, state, inputs, ref_or_rev, shallow)?
438        }
439        // ID provided, no URI, interactive: show URI input for that ID
440        (Some(id), None, true) => {
441            change_uri_interactive(editor, state, inputs, &id, ref_or_rev, shallow)?
442        }
443        // Both ID and URI provided - non-interactive
444        (Some(id_val), Some(uri_str), _) => {
445            change_with_id_and_uri(id_val, uri_str, ref_or_rev, shallow)?
446        }
447        // Only one positional arg (in id position), infer ID from URI
448        (Some(uri), None, false) | (None, Some(uri), false) => {
449            change_infer_id(uri, ref_or_rev, shallow)?
450        }
451        // No arguments and non-interactive
452        (None, None, false) => {
453            return Err(CommandError::NoId);
454        }
455    };
456
457    apply_change(editor, flake_edit, state, change)
458}
459
460/// Full interactive change: select input from list, then enter new URI.
461fn change_full_interactive(
462    editor: &Editor,
463    state: &AppState,
464    inputs: &crate::edit::InputMap,
465    ref_or_rev: Option<&str>,
466    shallow: bool,
467) -> Result<Change> {
468    let input_pairs: Vec<(String, String)> = sorted_input_ids(inputs)
469        .into_iter()
470        .map(|id| (id.clone(), inputs[id].url().trim_matches('"').to_string()))
471        .collect();
472
473    if input_pairs.is_empty() {
474        return Err(CommandError::NoInputs);
475    }
476
477    let tui_app = tui::App::change("Change", editor.text(), input_pairs, state.cache_config());
478    let Some(tui::AppResult::Change(tui_change)) = tui::run(tui_app)? else {
479        return Ok(Change::None);
480    };
481
482    // Apply CLI options to the TUI result
483    if let Change::Change { id, uri, .. } = tui_change {
484        let final_uri = uri
485            .map(|u| transform_uri(u, ref_or_rev, shallow))
486            .transpose()?;
487        Ok(Change::Change {
488            id,
489            uri: final_uri,
490            ref_or_rev: None,
491        })
492    } else {
493        Ok(tui_change)
494    }
495}
496
497/// Interactive change with ID already known: show URI input.
498fn change_uri_interactive(
499    editor: &Editor,
500    state: &AppState,
501    inputs: &crate::edit::InputMap,
502    id: &str,
503    ref_or_rev: Option<&str>,
504    shallow: bool,
505) -> Result<Change> {
506    let current_uri = inputs.get(id).map(|i| i.url().trim_matches('"'));
507    let tui_app = tui::App::change_uri(
508        "Change",
509        editor.text(),
510        id,
511        current_uri,
512        state.diff,
513        state.cache_config(),
514    );
515
516    let Some(tui::AppResult::Change(tui_change)) = tui::run(tui_app)? else {
517        return Ok(Change::None);
518    };
519
520    // Apply CLI options to the TUI result
521    if let Change::Change {
522        uri: Some(new_uri), ..
523    } = tui_change
524    {
525        let final_uri = transform_uri(new_uri, ref_or_rev, shallow)?;
526        Ok(Change::Change {
527            id: Some(id.to_string()),
528            uri: Some(final_uri),
529            ref_or_rev: None,
530        })
531    } else {
532        Err(CommandError::NoUri)
533    }
534}
535
536fn change_with_id_and_uri(
537    id: String,
538    uri: String,
539    ref_or_rev: Option<&str>,
540    shallow: bool,
541) -> Result<Change> {
542    let final_uri = transform_uri(uri, ref_or_rev, shallow)?;
543    Ok(Change::Change {
544        id: Some(id),
545        uri: Some(final_uri),
546        ref_or_rev: None,
547    })
548}
549
550/// Change with only URI provided, inferring ID from the flake reference.
551fn change_infer_id(uri: String, ref_or_rev: Option<&str>, shallow: bool) -> Result<Change> {
552    let flake_ref: NixUriResult<FlakeRef> = UrlWrapper::convert_or_parse(&uri);
553
554    let flake_ref = flake_ref.map_err(|_| CommandError::CouldNotInferId(uri.clone()))?;
555    let flake_ref =
556        apply_uri_options(flake_ref, ref_or_rev, shallow).map_err(CommandError::CouldNotInferId)?;
557
558    let final_uri = if flake_ref.to_string().is_empty() {
559        uri.clone()
560    } else {
561        flake_ref.to_string()
562    };
563
564    let id = flake_ref.id().ok_or(CommandError::CouldNotInferId(uri))?;
565
566    Ok(Change::Change {
567        id: Some(id),
568        uri: Some(final_uri),
569        ref_or_rev: None,
570    })
571}
572
573pub fn update(
574    editor: &Editor,
575    flake_edit: &mut FlakeEdit,
576    state: &AppState,
577    id: Option<String>,
578    init: bool,
579) -> Result<()> {
580    let inputs = flake_edit.list().clone();
581    let input_ids = sorted_input_ids_owned(&inputs);
582
583    if let Some(id) = id {
584        let mut updater = updater(editor, inputs);
585        updater.update_all_inputs_to_latest_semver(Some(id), init);
586        let change = updater.get_changes();
587        editor.apply_or_diff(&change, state)?;
588    } else if state.interactive {
589        if input_ids.is_empty() {
590            return Err(CommandError::NoInputs);
591        }
592
593        let display_items: Vec<String> = input_ids
594            .iter()
595            .map(|id| {
596                let input = &inputs[id];
597                let version = input
598                    .url()
599                    .trim_matches('"')
600                    .parse::<FlakeRef>()
601                    .ok()
602                    .and_then(|f| f.get_ref_or_rev());
603                match version {
604                    Some(v) if !v.is_empty() => format!("{} - {}", id, v),
605                    _ => id.clone(),
606                }
607            })
608            .collect();
609
610        interactive_multi_select(
611            editor,
612            state,
613            "Update",
614            "Space select, U all, ^D diff",
615            display_items,
616            |selected| {
617                // Strip version suffix from display strings to get IDs
618                let ids: Vec<String> = selected
619                    .iter()
620                    .map(|s| s.split(" - ").next().unwrap_or(s).to_string())
621                    .collect();
622                let mut updater = updater(editor, inputs.clone());
623                for id in &ids {
624                    updater.update_all_inputs_to_latest_semver(Some(id.clone()), init);
625                }
626                updater.get_changes()
627            },
628        )?;
629    } else {
630        let mut updater = updater(editor, inputs);
631        for id in &input_ids {
632            updater.update_all_inputs_to_latest_semver(Some(id.clone()), init);
633        }
634        let change = updater.get_changes();
635        editor.apply_or_diff(&change, state)?;
636    }
637
638    Ok(())
639}
640
641pub fn pin(
642    editor: &Editor,
643    flake_edit: &mut FlakeEdit,
644    state: &AppState,
645    id: Option<String>,
646    rev: Option<String>,
647) -> Result<()> {
648    let inputs = flake_edit.list().clone();
649    let input_ids = sorted_input_ids_owned(&inputs);
650
651    if let Some(id) = id {
652        let lock = FlakeLock::from_default_path().map_err(|_| CommandError::NoLock)?;
653        let target_rev = if let Some(rev) = rev {
654            rev
655        } else {
656            lock.rev_for(&id)
657                .map_err(|_| CommandError::InputNotFound(id.clone()))?
658        };
659        let mut updater = updater(editor, inputs);
660        updater.pin_input_to_ref(&id, &target_rev);
661        let change = updater.get_changes();
662        editor.apply_or_diff(&change, state)?;
663        if !state.diff {
664            println!("Pinned input: {} to {}", id, target_rev);
665        }
666    } else if state.interactive {
667        if input_ids.is_empty() {
668            return Err(CommandError::NoInputs);
669        }
670        let lock = FlakeLock::from_default_path().map_err(|_| CommandError::NoLock)?;
671
672        interactive_single_select(
673            editor,
674            state,
675            "Pin",
676            "Select input",
677            input_ids,
678            |id| {
679                let target_rev = lock
680                    .rev_for(id)
681                    .map_err(|_| CommandError::InputNotFound(id.to_string()))?;
682                let mut updater = updater(editor, inputs.clone());
683                updater.pin_input_to_ref(id, &target_rev);
684                Ok((updater.get_changes(), target_rev))
685            },
686            |id, target_rev| println!("Pinned input: {} to {}", id, target_rev),
687        )?;
688    } else {
689        return Err(CommandError::NoId);
690    }
691
692    Ok(())
693}
694
695pub fn unpin(
696    editor: &Editor,
697    flake_edit: &mut FlakeEdit,
698    state: &AppState,
699    id: Option<String>,
700) -> Result<()> {
701    let inputs = flake_edit.list().clone();
702    let input_ids = sorted_input_ids_owned(&inputs);
703
704    if let Some(id) = id {
705        let mut updater = updater(editor, inputs);
706        updater.unpin_input(&id);
707        let change = updater.get_changes();
708        editor.apply_or_diff(&change, state)?;
709        if !state.diff {
710            println!("Unpinned input: {}", id);
711        }
712    } else if state.interactive {
713        if input_ids.is_empty() {
714            return Err(CommandError::NoInputs);
715        }
716
717        interactive_single_select(
718            editor,
719            state,
720            "Unpin",
721            "Select input",
722            input_ids,
723            |id| {
724                let mut updater = updater(editor, inputs.clone());
725                updater.unpin_input(id);
726                Ok((updater.get_changes(), ()))
727            },
728            |id, ()| println!("Unpinned input: {}", id),
729        )?;
730    } else {
731        return Err(CommandError::NoId);
732    }
733
734    Ok(())
735}
736
737pub fn list(flake_edit: &mut FlakeEdit, format: &crate::cli::ListFormat) -> Result<()> {
738    let inputs = flake_edit.list();
739    crate::app::handler::list_inputs(inputs, format);
740    Ok(())
741}
742
743pub fn follow(
744    editor: &Editor,
745    flake_edit: &mut FlakeEdit,
746    state: &AppState,
747    input: Option<String>,
748    target: Option<String>,
749    auto: bool,
750) -> Result<()> {
751    if auto {
752        return follow_auto(editor, flake_edit, state);
753    }
754
755    let change = if let (Some(input_val), Some(target_val)) = (input.clone(), target) {
756        // Both provided - non-interactive
757        Change::Follows {
758            input: input_val.into(),
759            target: target_val,
760        }
761    } else if state.interactive {
762        // Interactive mode
763        let Some(ctx) = load_follow_context(flake_edit, state)? else {
764            return Ok(());
765        };
766        let top_level_vec: Vec<String> = ctx.top_level_inputs.into_iter().collect();
767
768        let tui_app = if let Some(input_val) = input {
769            tui::App::follow_target("Follow", editor.text(), input_val, top_level_vec)
770        } else {
771            tui::App::follow("Follow", editor.text(), ctx.nested_inputs, top_level_vec)
772        };
773
774        let Some(tui::AppResult::Change(tui_change)) = tui::run(tui_app)? else {
775            return Ok(());
776        };
777        tui_change
778    } else {
779        return Err(CommandError::NoId);
780    };
781
782    apply_change(editor, flake_edit, state, change)
783}
784
785/// Automatically follow inputs based on lockfile information.
786///
787/// For each nested input (e.g., "crane.nixpkgs"), if there's a matching
788/// top-level input with the same name (e.g., "nixpkgs"), create a follows
789/// relationship. Skips inputs that already have follows set.
790fn follow_auto(editor: &Editor, flake_edit: &mut FlakeEdit, state: &AppState) -> Result<()> {
791    let Some(ctx) = load_follow_context(flake_edit, state)? else {
792        return Ok(());
793    };
794
795    // Collect candidates: nested inputs that match a top-level input
796    let to_follow: Vec<(String, String)> = ctx
797        .nested_inputs
798        .iter()
799        .filter(|nested| nested.follows.is_none())
800        .filter_map(|nested| {
801            let nested_name = nested.path.split('.').next_back().unwrap_or(&nested.path);
802            let parent = nested.path.split('.').next().unwrap_or(&nested.path);
803
804            if !ctx.top_level_inputs.contains(nested_name) {
805                return None;
806            }
807
808            // Skip if target already follows from parent (would create cycle)
809            // e.g., treefmt-nix.follows = "clan-core/treefmt-nix" means we can't
810            // add clan-core.inputs.treefmt-nix.follows = "treefmt-nix"
811            if let Some(target_input) = ctx.inputs.get(nested_name)
812                && is_follows_reference_to_parent(target_input.url(), parent)
813            {
814                tracing::debug!(
815                    "Skipping {} -> {}: would create cycle (target follows {}/...)",
816                    nested.path,
817                    nested_name,
818                    parent
819                );
820                return None;
821            }
822
823            Some((nested.path.clone(), nested_name.to_string()))
824        })
825        .collect();
826
827    if to_follow.is_empty() {
828        println!("All inputs are already deduplicated.");
829        return Ok(());
830    }
831
832    // Apply all changes in memory
833    let mut current_text = editor.text();
834    let mut applied: Vec<(&str, &str)> = Vec::new();
835
836    for (input_path, target) in &to_follow {
837        let change = Change::Follows {
838            input: input_path.clone().into(),
839            target: target.clone(),
840        };
841
842        let mut temp_flake_edit =
843            FlakeEdit::from_text(&current_text).map_err(CommandError::FlakeEdit)?;
844
845        match temp_flake_edit.apply_change(change) {
846            Ok(Some(resulting_text)) => {
847                let validation = validate::validate(&resulting_text);
848                if validation.is_ok() {
849                    current_text = resulting_text;
850                    applied.push((input_path, target));
851                } else {
852                    for err in validation.errors {
853                        eprintln!("Error applying follows for {}: {}", input_path, err);
854                    }
855                }
856            }
857            Ok(None) => eprintln!("Could not create follows for {}", input_path),
858            Err(e) => eprintln!("Error applying follows for {}: {}", input_path, e),
859        }
860    }
861
862    if applied.is_empty() {
863        return Ok(());
864    }
865
866    if state.diff {
867        let original = editor.text();
868        let diff = crate::diff::Diff::new(&original, &current_text);
869        diff.compare();
870    } else {
871        editor.apply_or_diff(&current_text, state)?;
872        println!(
873            "Deduplicated {} {}.",
874            applied.len(),
875            if applied.len() == 1 {
876                "input"
877            } else {
878                "inputs"
879            }
880        );
881        for (input_path, target) in &applied {
882            let nested_name = input_path.split('.').next_back().unwrap_or(input_path);
883            let parent = input_path.split('.').next().unwrap_or(input_path);
884            println!("  {}.{} → {}", parent, nested_name, target);
885        }
886    }
887
888    Ok(())
889}
890
891fn apply_change(
892    editor: &Editor,
893    flake_edit: &mut FlakeEdit,
894    state: &AppState,
895    change: Change,
896) -> Result<()> {
897    match flake_edit.apply_change(change.clone()) {
898        Ok(Some(resulting_change)) => {
899            let validation = validate::validate(&resulting_change);
900            if validation.has_errors() {
901                eprintln!("There are errors in the changes:");
902                for e in &validation.errors {
903                    tracing::error!("Error: {e}");
904                }
905                eprintln!("{}", resulting_change);
906                eprintln!("There were errors in the changes, the changes have not been applied.");
907                std::process::exit(1);
908            }
909
910            editor.apply_or_diff(&resulting_change, state)?;
911
912            if !state.diff {
913                // Cache added entries for future completions
914                if let Change::Add {
915                    id: Some(id),
916                    uri: Some(uri),
917                    ..
918                } = &change
919                {
920                    let mut cache = crate::cache::Cache::load();
921                    cache.add_entry(id.clone(), uri.clone());
922                    if let Err(e) = cache.commit() {
923                        tracing::debug!("Could not write to cache: {}", e);
924                    }
925                }
926
927                for msg in change.success_messages() {
928                    println!("{}", msg);
929                }
930            }
931        }
932        Err(e) => {
933            return Err(e.into());
934        }
935        Ok(None) => {
936            if change.is_remove() {
937                return Err(CommandError::CouldNotRemove(
938                    change.id().map(|id| id.to_string()).unwrap_or_default(),
939                ));
940            }
941            if change.is_follows() {
942                let id = change.id().map(|id| id.to_string()).unwrap_or_default();
943                eprintln!("The follows relationship for {} could not be created.", id);
944                eprintln!(
945                    "\nPlease check that the input exists in the flake.nix file.\n\
946                     Use dot notation: `flake-edit follow <input>.<nested-input> <target>`\n\
947                     Example: `flake-edit follow rust-overlay.nixpkgs nixpkgs`"
948                );
949                std::process::exit(1);
950            }
951            println!("Nothing changed.");
952        }
953    }
954
955    Ok(())
956}
957
958#[cfg(test)]
959mod tests {
960    use super::*;
961
962    #[test]
963    fn test_is_follows_reference_to_parent() {
964        // Test case: treefmt-nix.follows = "clan-core/treefmt-nix"
965        // The URL would be stored as "\"clan-core/treefmt-nix\""
966        assert!(is_follows_reference_to_parent(
967            "\"clan-core/treefmt-nix\"",
968            "clan-core"
969        ));
970
971        // Also test without surrounding quotes (defensive)
972        assert!(is_follows_reference_to_parent(
973            "clan-core/treefmt-nix",
974            "clan-core"
975        ));
976
977        // Test with different parent
978        assert!(is_follows_reference_to_parent(
979            "\"some-input/nixpkgs\"",
980            "some-input"
981        ));
982
983        // Negative test: regular URL should not match
984        assert!(!is_follows_reference_to_parent(
985            "\"github:nixos/nixpkgs\"",
986            "clan-core"
987        ));
988
989        // Negative test: URL that contains the parent but doesn't start with it
990        assert!(!is_follows_reference_to_parent(
991            "\"github:foo/clan-core-utils\"",
992            "clan-core"
993        ));
994
995        // Negative test: parent name matches but not followed by /
996        assert!(!is_follows_reference_to_parent(
997            "\"clan-core-extended\"",
998            "clan-core"
999        ));
1000
1001        // Edge case: empty URL
1002        assert!(!is_follows_reference_to_parent("", "clan-core"));
1003
1004        // Edge case: just quotes
1005        assert!(!is_follows_reference_to_parent("\"\"", "clan-core"));
1006    }
1007}