flake_edit/app/
commands.rs

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