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