Skip to main content

flake_edit/app/
commands.rs

1use std::collections::{HashMap, HashSet};
2
3use nix_uri::urls::UrlWrapper;
4use nix_uri::{FlakeRef, NixUriResult};
5use ropey::Rope;
6
7use crate::change::{Change, split_quoted_path};
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("Input '{0}' has no pinnable URL (it may use follows or a non-standard format)")]
61    InputNotPinnable(String),
62
63    #[error("The input could not be removed: {0}")]
64    CouldNotRemove(String),
65}
66
67/// Load the flake.lock file, using the path from state if provided.
68fn load_flake_lock(state: &AppState) -> std::result::Result<FlakeLock, FlakeEditError> {
69    if let Some(lock_path) = &state.lock_file {
70        FlakeLock::from_file(lock_path)
71    } else {
72        FlakeLock::from_default_path()
73    }
74}
75
76struct FollowContext {
77    nested_inputs: Vec<NestedInput>,
78    top_level_inputs: HashSet<String>,
79    /// Full input map for checking URLs (needed for cycle detection)
80    inputs: crate::edit::InputMap,
81}
82
83/// Check if a top-level input's URL is a follows reference to a specific parent input.
84/// For example, `treefmt-nix.follows = "clan-core/treefmt-nix"` has URL `"clan-core/treefmt-nix"`
85/// which is a follows reference to the `clan-core` parent.
86fn is_follows_reference_to_parent(url: &str, parent: &str) -> bool {
87    let url_trimmed = url.trim_matches('"');
88    url_trimmed.starts_with(&format!("{}/", parent))
89}
90
91/// Collect nested follows paths already declared in flake.nix.
92///
93/// Paths are normalised so that they can be compared against lock-file
94/// paths regardless of whether the Nix source quotes the attribute names.
95fn collect_existing_follows(inputs: &InputMap) -> HashSet<String> {
96    let mut existing = HashSet::new();
97    for (input_id, input) in inputs {
98        for follows in input.follows() {
99            if let Follows::Indirect(nested_name, _target) = follows {
100                let raw = format!("{}.{}", input_id, nested_name);
101                existing.insert(normalize_nested_path(&raw));
102            }
103        }
104    }
105    existing
106}
107
108/// Convert a lockfile follows path like "parent.child" to flake follows syntax "parent/child".
109fn lock_follows_to_flake_target(target: &str) -> String {
110    if target.contains('.') {
111        target.replace('.', "/")
112    } else {
113        target.to_string()
114    }
115}
116
117/// Load nested inputs from lockfile and top-level inputs from flake.nix.
118fn load_follow_context(
119    flake_edit: &mut FlakeEdit,
120    state: &AppState,
121) -> Result<Option<FollowContext>> {
122    let nested_inputs: Vec<NestedInput> = match load_flake_lock(state) {
123        Ok(lock) => lock.nested_inputs(),
124        Err(e) => {
125            let lock_path = state
126                .lock_file
127                .as_ref()
128                .map(|p| p.display().to_string())
129                .unwrap_or_else(|| "flake.lock".to_string());
130            return Err(CommandError::LockFileError {
131                path: lock_path,
132                source: e,
133            });
134        }
135    };
136
137    if nested_inputs.is_empty() {
138        return Ok(None);
139    }
140
141    let inputs = flake_edit.list().clone();
142    let top_level_inputs: HashSet<String> = inputs.keys().cloned().collect();
143
144    if top_level_inputs.is_empty() {
145        return Err(CommandError::NoInputs);
146    }
147
148    Ok(Some(FollowContext {
149        nested_inputs,
150        top_level_inputs,
151        inputs,
152    }))
153}
154
155/// Result of running the confirm-or-apply workflow.
156enum ConfirmResult {
157    /// Change was applied successfully.
158    Applied,
159    /// User cancelled (Escape or window closed).
160    Cancelled,
161    /// User wants to go back to selection.
162    Back,
163}
164
165/// Run an interactive single-select loop with confirmation.
166///
167/// 1. Show selection screen
168/// 2. User selects an item
169/// 3. Create change based on selection
170/// 4. Show confirmation (with diff if requested)
171/// 5. Apply or go back
172fn interactive_single_select<F, OnApplied, ExtraData>(
173    editor: &Editor,
174    state: &AppState,
175    title: &str,
176    prompt: &str,
177    items: Vec<String>,
178    make_change: F,
179    on_applied: OnApplied,
180) -> Result<()>
181where
182    F: Fn(&str) -> Result<(String, ExtraData)>,
183    OnApplied: Fn(&str, ExtraData),
184{
185    loop {
186        let select_app = tui::App::select_one(title, prompt, items.clone(), state.diff);
187        let Some(tui::AppResult::SingleSelect(result)) = tui::run(select_app)? else {
188            return Ok(());
189        };
190        let tui::SingleSelectResult {
191            item: id,
192            show_diff,
193        } = result;
194        let (change, extra_data) = make_change(&id)?;
195
196        match confirm_or_apply(editor, state, title, &change, show_diff)? {
197            ConfirmResult::Applied => {
198                on_applied(&id, extra_data);
199                break;
200            }
201            ConfirmResult::Back => continue,
202            ConfirmResult::Cancelled => return Ok(()),
203        }
204    }
205    Ok(())
206}
207
208/// Like `interactive_single_select` but for multi-selection.
209fn interactive_multi_select<F>(
210    editor: &Editor,
211    state: &AppState,
212    title: &str,
213    prompt: &str,
214    items: Vec<String>,
215    make_change: F,
216) -> Result<()>
217where
218    F: Fn(&[String]) -> String,
219{
220    loop {
221        let select_app = tui::App::select_many(title, prompt, items.clone(), state.diff);
222        let Some(tui::AppResult::MultiSelect(result)) = tui::run(select_app)? else {
223            return Ok(());
224        };
225        let tui::MultiSelectResultData {
226            items: selected,
227            show_diff,
228        } = result;
229        let change = make_change(&selected);
230
231        match confirm_or_apply(editor, state, title, &change, show_diff)? {
232            ConfirmResult::Applied => break,
233            ConfirmResult::Back => continue,
234            ConfirmResult::Cancelled => return Ok(()),
235        }
236    }
237    Ok(())
238}
239
240/// Run the confirm-or-apply workflow for a change.
241///
242/// If `show_diff` is true, shows a confirmation screen with the diff.
243/// Otherwise applies the change directly.
244///
245/// Returns `Back` if user wants to go back to selection, `Applied` if the
246/// change was applied, or `Cancelled` if the user cancelled.
247fn confirm_or_apply(
248    editor: &Editor,
249    state: &AppState,
250    context: &str,
251    change: &str,
252    show_diff: bool,
253) -> Result<ConfirmResult> {
254    if show_diff || state.diff {
255        let diff = crate::diff::Diff::new(&editor.text(), change).to_string_plain();
256        let confirm_app = tui::App::confirm(context, &diff);
257        let Some(tui::AppResult::Confirm(action)) = tui::run(confirm_app)? else {
258            return Ok(ConfirmResult::Cancelled);
259        };
260        match action {
261            tui::ConfirmResultAction::Apply => {
262                let mut apply_state = state.clone();
263                apply_state.diff = false;
264                editor.apply_or_diff(change, &apply_state)?;
265                Ok(ConfirmResult::Applied)
266            }
267            tui::ConfirmResultAction::Back => Ok(ConfirmResult::Back),
268            tui::ConfirmResultAction::Exit => Ok(ConfirmResult::Cancelled),
269        }
270    } else {
271        editor.apply_or_diff(change, state)?;
272        Ok(ConfirmResult::Applied)
273    }
274}
275
276/// Apply URI options (ref_or_rev, shallow) to a FlakeRef.
277fn apply_uri_options(
278    mut flake_ref: FlakeRef,
279    ref_or_rev: Option<&str>,
280    shallow: bool,
281) -> std::result::Result<FlakeRef, String> {
282    if let Some(ror) = ref_or_rev {
283        flake_ref.r#type.ref_or_rev(Some(ror.to_string())).map_err(|e| {
284            format!(
285                "Cannot apply --ref-or-rev: {}. \
286                The --ref-or-rev option only works with git forge types (github:, gitlab:, sourcehut:) and indirect types (flake:). \
287                For other URI types, use ?ref= or ?rev= query parameters in the URI itself.",
288                e
289            )
290        })?;
291    }
292    if shallow {
293        flake_ref.params.set_shallow(Some("1".to_string()));
294    }
295    Ok(flake_ref)
296}
297
298/// Transform a URI string by applying ref_or_rev and shallow options if specified.
299///
300/// Always validates the URI through nix-uri parsing.
301/// If neither option is set, returns the original URI unchanged after validation.
302/// Otherwise, applies the options and returns the transformed string.
303fn transform_uri(uri: String, ref_or_rev: Option<&str>, shallow: bool) -> Result<String> {
304    let flake_ref: FlakeRef = uri
305        .parse()
306        .map_err(|e| CommandError::InvalidUri(format!("{}: {}", uri, e)))?;
307
308    if ref_or_rev.is_none() && !shallow {
309        return Ok(uri);
310    }
311
312    apply_uri_options(flake_ref, ref_or_rev, shallow)
313        .map(|f| f.to_string())
314        .map_err(CommandError::CouldNotInferId)
315}
316
317#[derive(Default)]
318pub struct UriOptions<'a> {
319    pub ref_or_rev: Option<&'a str>,
320    pub shallow: bool,
321    pub no_flake: bool,
322}
323
324pub fn add(
325    editor: &Editor,
326    flake_edit: &mut FlakeEdit,
327    state: &AppState,
328    id: Option<String>,
329    uri: Option<String>,
330    opts: UriOptions<'_>,
331) -> Result<()> {
332    let change = match (id, uri, state.interactive) {
333        // Both ID and URI provided - non-interactive add
334        (Some(id_val), Some(uri_str), _) => add_with_id_and_uri(id_val, uri_str, &opts)?,
335        // Interactive mode - show TUI (with or without prefill)
336        (id, None, true) | (None, id, true) => {
337            add_interactive(editor, state, id.as_deref(), &opts)?
338        }
339        // Non-interactive with only one arg (could be in id or uri position) - infer ID
340        (Some(uri), None, false) | (None, Some(uri), false) => add_infer_id(uri, &opts)?,
341        // No arguments and non-interactive
342        (None, None, false) => {
343            return Err(CommandError::NoUri);
344        }
345    };
346
347    apply_change(editor, flake_edit, state, change)
348}
349
350fn add_with_id_and_uri(id: String, uri: String, opts: &UriOptions<'_>) -> Result<Change> {
351    let final_uri = transform_uri(uri, opts.ref_or_rev, opts.shallow)?;
352    Ok(Change::Add {
353        id: Some(id),
354        uri: Some(final_uri),
355        flake: !opts.no_flake,
356    })
357}
358
359fn add_interactive(
360    editor: &Editor,
361    state: &AppState,
362    prefill_uri: Option<&str>,
363    opts: &UriOptions<'_>,
364) -> Result<Change> {
365    let tui_app = tui::App::add("Add", editor.text(), prefill_uri, state.cache_config());
366    let Some(tui::AppResult::Change(tui_change)) = tui::run(tui_app)? else {
367        // User cancelled - return a no-op change
368        return Ok(Change::None);
369    };
370
371    // Apply CLI options to the TUI result
372    if let Change::Add { id, uri, flake } = tui_change {
373        let final_uri = uri
374            .map(|u| transform_uri(u, opts.ref_or_rev, opts.shallow))
375            .transpose()?;
376        Ok(Change::Add {
377            id,
378            uri: final_uri,
379            flake: flake && !opts.no_flake,
380        })
381    } else {
382        Ok(tui_change)
383    }
384}
385
386/// Add with only URI provided, inferring ID from the flake reference.
387fn add_infer_id(uri: String, opts: &UriOptions<'_>) -> Result<Change> {
388    let flake_ref: NixUriResult<FlakeRef> = UrlWrapper::convert_or_parse(&uri);
389
390    let (inferred_id, final_uri) = if let Ok(flake_ref) = flake_ref {
391        let flake_ref = apply_uri_options(flake_ref, opts.ref_or_rev, opts.shallow)
392            .map_err(CommandError::CouldNotInferId)?;
393        let parsed_uri = flake_ref.to_string();
394        let final_uri = if parsed_uri.is_empty() || parsed_uri == "none" {
395            uri.clone()
396        } else {
397            parsed_uri
398        };
399        (flake_ref.id(), final_uri)
400    } else {
401        (None, uri.clone())
402    };
403
404    let final_id = inferred_id.ok_or(CommandError::CouldNotInferId(uri))?;
405
406    Ok(Change::Add {
407        id: Some(final_id),
408        uri: Some(final_uri),
409        flake: !opts.no_flake,
410    })
411}
412
413pub fn remove(
414    editor: &Editor,
415    flake_edit: &mut FlakeEdit,
416    state: &AppState,
417    id: Option<String>,
418) -> Result<()> {
419    let change = if let Some(id) = id {
420        Change::Remove {
421            ids: vec![id.into()],
422        }
423    } else if state.interactive {
424        let inputs = flake_edit.list();
425        let mut removable: Vec<String> = Vec::new();
426        for input_id in sorted_input_ids(inputs) {
427            let input = &inputs[input_id];
428            removable.push(input_id.clone());
429            for follows in input.follows() {
430                if let crate::input::Follows::Indirect(from, to) = follows {
431                    removable.push(format!("{}.{} => {}", input_id, from, to));
432                }
433            }
434        }
435        if removable.is_empty() {
436            return Err(CommandError::NoInputs);
437        }
438
439        let tui_app = tui::App::remove("Remove", editor.text(), removable);
440        let Some(tui::AppResult::Change(tui_change)) = tui::run(tui_app)? else {
441            return Ok(());
442        };
443
444        // Strip " => target" suffix for follows entries
445        if let Change::Remove { ids } = tui_change {
446            let stripped_ids: Vec<_> = ids
447                .iter()
448                .map(|id| {
449                    id.to_string()
450                        .split(" => ")
451                        .next()
452                        .unwrap_or(&id.to_string())
453                        .to_string()
454                        .into()
455                })
456                .collect();
457            Change::Remove { ids: stripped_ids }
458        } else {
459            tui_change
460        }
461    } else {
462        return Err(CommandError::NoId);
463    };
464
465    apply_change(editor, flake_edit, state, change)
466}
467
468pub fn change(
469    editor: &Editor,
470    flake_edit: &mut FlakeEdit,
471    state: &AppState,
472    id: Option<String>,
473    uri: Option<String>,
474    ref_or_rev: Option<&str>,
475    shallow: bool,
476) -> Result<()> {
477    let inputs = flake_edit.list();
478
479    let change = match (id, uri, state.interactive) {
480        // Full interactive: select input, then enter URI
481        // Also handles case where only URI provided interactively (need to select input)
482        (None, None, true) | (None, Some(_), true) => {
483            change_full_interactive(editor, state, inputs, ref_or_rev, shallow)?
484        }
485        // ID provided, no URI, interactive: show URI input for that ID
486        (Some(id), None, true) => {
487            change_uri_interactive(editor, state, inputs, &id, ref_or_rev, shallow)?
488        }
489        // Both ID and URI provided - non-interactive
490        (Some(id_val), Some(uri_str), _) => {
491            change_with_id_and_uri(id_val, uri_str, ref_or_rev, shallow)?
492        }
493        // Only one positional arg (in id position), infer ID from URI
494        (Some(uri), None, false) | (None, Some(uri), false) => {
495            change_infer_id(uri, ref_or_rev, shallow)?
496        }
497        // No arguments and non-interactive
498        (None, None, false) => {
499            return Err(CommandError::NoId);
500        }
501    };
502
503    apply_change(editor, flake_edit, state, change)
504}
505
506/// Full interactive change: select input from list, then enter new URI.
507fn change_full_interactive(
508    editor: &Editor,
509    state: &AppState,
510    inputs: &crate::edit::InputMap,
511    ref_or_rev: Option<&str>,
512    shallow: bool,
513) -> Result<Change> {
514    let input_pairs: Vec<(String, String)> = sorted_input_ids(inputs)
515        .into_iter()
516        .map(|id| (id.clone(), inputs[id].url().trim_matches('"').to_string()))
517        .collect();
518
519    if input_pairs.is_empty() {
520        return Err(CommandError::NoInputs);
521    }
522
523    let tui_app = tui::App::change("Change", editor.text(), input_pairs, state.cache_config());
524    let Some(tui::AppResult::Change(tui_change)) = tui::run(tui_app)? else {
525        return Ok(Change::None);
526    };
527
528    // Apply CLI options to the TUI result
529    if let Change::Change { id, uri, .. } = tui_change {
530        let final_uri = uri
531            .map(|u| transform_uri(u, ref_or_rev, shallow))
532            .transpose()?;
533        Ok(Change::Change {
534            id,
535            uri: final_uri,
536            ref_or_rev: None,
537        })
538    } else {
539        Ok(tui_change)
540    }
541}
542
543/// Interactive change with ID already known: show URI input.
544fn change_uri_interactive(
545    editor: &Editor,
546    state: &AppState,
547    inputs: &crate::edit::InputMap,
548    id: &str,
549    ref_or_rev: Option<&str>,
550    shallow: bool,
551) -> Result<Change> {
552    let current_uri = inputs.get(id).map(|i| i.url().trim_matches('"'));
553    let tui_app = tui::App::change_uri(
554        "Change",
555        editor.text(),
556        id,
557        current_uri,
558        state.diff,
559        state.cache_config(),
560    );
561
562    let Some(tui::AppResult::Change(tui_change)) = tui::run(tui_app)? else {
563        return Ok(Change::None);
564    };
565
566    // Apply CLI options to the TUI result
567    if let Change::Change {
568        uri: Some(new_uri), ..
569    } = tui_change
570    {
571        let final_uri = transform_uri(new_uri, ref_or_rev, shallow)?;
572        Ok(Change::Change {
573            id: Some(id.to_string()),
574            uri: Some(final_uri),
575            ref_or_rev: None,
576        })
577    } else {
578        Err(CommandError::NoUri)
579    }
580}
581
582fn change_with_id_and_uri(
583    id: String,
584    uri: String,
585    ref_or_rev: Option<&str>,
586    shallow: bool,
587) -> Result<Change> {
588    let final_uri = transform_uri(uri, ref_or_rev, shallow)?;
589    Ok(Change::Change {
590        id: Some(id),
591        uri: Some(final_uri),
592        ref_or_rev: None,
593    })
594}
595
596/// Change with only URI provided, inferring ID from the flake reference.
597fn change_infer_id(uri: String, ref_or_rev: Option<&str>, shallow: bool) -> Result<Change> {
598    let flake_ref: NixUriResult<FlakeRef> = UrlWrapper::convert_or_parse(&uri);
599
600    let flake_ref = flake_ref.map_err(|_| CommandError::CouldNotInferId(uri.clone()))?;
601    let flake_ref =
602        apply_uri_options(flake_ref, ref_or_rev, shallow).map_err(CommandError::CouldNotInferId)?;
603
604    let final_uri = if flake_ref.to_string().is_empty() {
605        uri.clone()
606    } else {
607        flake_ref.to_string()
608    };
609
610    let id = flake_ref.id().ok_or(CommandError::CouldNotInferId(uri))?;
611
612    Ok(Change::Change {
613        id: Some(id),
614        uri: Some(final_uri),
615        ref_or_rev: None,
616    })
617}
618
619pub fn update(
620    editor: &Editor,
621    flake_edit: &mut FlakeEdit,
622    state: &AppState,
623    id: Option<String>,
624    init: bool,
625) -> Result<()> {
626    let inputs = flake_edit.list().clone();
627    let input_ids = sorted_input_ids_owned(&inputs);
628
629    if let Some(id) = id {
630        let mut updater = updater(editor, inputs);
631        updater.update_all_inputs_to_latest_semver(Some(id), init);
632        let change = updater.get_changes();
633        editor.apply_or_diff(&change, state)?;
634    } else if state.interactive {
635        if input_ids.is_empty() {
636            return Err(CommandError::NoInputs);
637        }
638
639        let display_items: Vec<String> = input_ids
640            .iter()
641            .map(|id| {
642                let input = &inputs[id];
643                let version = input
644                    .url()
645                    .trim_matches('"')
646                    .parse::<FlakeRef>()
647                    .ok()
648                    .and_then(|f| f.get_ref_or_rev());
649                match version {
650                    Some(v) if !v.is_empty() => format!("{} - {}", id, v),
651                    _ => id.clone(),
652                }
653            })
654            .collect();
655
656        interactive_multi_select(
657            editor,
658            state,
659            "Update",
660            "Space select, U all, ^D diff",
661            display_items,
662            |selected| {
663                // Strip version suffix from display strings to get IDs
664                let ids: Vec<String> = selected
665                    .iter()
666                    .map(|s| s.split(" - ").next().unwrap_or(s).to_string())
667                    .collect();
668                let mut updater = updater(editor, inputs.clone());
669                for id in &ids {
670                    updater.update_all_inputs_to_latest_semver(Some(id.clone()), init);
671                }
672                updater.get_changes()
673            },
674        )?;
675    } else {
676        let mut updater = updater(editor, inputs);
677        for id in &input_ids {
678            updater.update_all_inputs_to_latest_semver(Some(id.clone()), init);
679        }
680        let change = updater.get_changes();
681        editor.apply_or_diff(&change, state)?;
682    }
683
684    Ok(())
685}
686
687pub fn pin(
688    editor: &Editor,
689    flake_edit: &mut FlakeEdit,
690    state: &AppState,
691    id: Option<String>,
692    rev: Option<String>,
693) -> Result<()> {
694    let inputs = flake_edit.list().clone();
695    let input_ids = sorted_input_ids_owned(&inputs);
696
697    if let Some(id) = id {
698        let lock = load_flake_lock(state).map_err(|e| CommandError::LockFileError {
699            path: state
700                .lock_file
701                .as_ref()
702                .map(|p| p.display().to_string())
703                .unwrap_or_else(|| "flake.lock".to_string()),
704            source: e,
705        })?;
706        let target_rev = if let Some(rev) = rev {
707            rev
708        } else {
709            lock.rev_for(&id)
710                .map_err(|_| CommandError::InputNotFound(id.clone()))?
711        };
712        let mut updater = updater(editor, inputs);
713        updater
714            .pin_input_to_ref(&id, &target_rev)
715            .map_err(CommandError::InputNotPinnable)?;
716        let change = updater.get_changes();
717        editor.apply_or_diff(&change, state)?;
718        if !state.diff {
719            println!("Pinned input: {} to {}", id, target_rev);
720        }
721    } else if state.interactive {
722        if input_ids.is_empty() {
723            return Err(CommandError::NoInputs);
724        }
725        let lock = load_flake_lock(state).map_err(|e| CommandError::LockFileError {
726            path: state
727                .lock_file
728                .as_ref()
729                .map(|p| p.display().to_string())
730                .unwrap_or_else(|| "flake.lock".to_string()),
731            source: e,
732        })?;
733
734        interactive_single_select(
735            editor,
736            state,
737            "Pin",
738            "Select input",
739            input_ids,
740            |id| {
741                let target_rev = lock
742                    .rev_for(id)
743                    .map_err(|_| CommandError::InputNotFound(id.to_string()))?;
744                let mut updater = updater(editor, inputs.clone());
745                updater
746                    .pin_input_to_ref(id, &target_rev)
747                    .map_err(CommandError::InputNotPinnable)?;
748                Ok((updater.get_changes(), target_rev))
749            },
750            |id, target_rev| println!("Pinned input: {} to {}", id, target_rev),
751        )?;
752    } else {
753        return Err(CommandError::NoId);
754    }
755
756    Ok(())
757}
758
759pub fn unpin(
760    editor: &Editor,
761    flake_edit: &mut FlakeEdit,
762    state: &AppState,
763    id: Option<String>,
764) -> Result<()> {
765    let inputs = flake_edit.list().clone();
766    let input_ids = sorted_input_ids_owned(&inputs);
767
768    if let Some(id) = id {
769        let mut updater = updater(editor, inputs);
770        updater
771            .unpin_input(&id)
772            .map_err(CommandError::InputNotPinnable)?;
773        let change = updater.get_changes();
774        editor.apply_or_diff(&change, state)?;
775        if !state.diff {
776            println!("Unpinned input: {}", id);
777        }
778    } else if state.interactive {
779        if input_ids.is_empty() {
780            return Err(CommandError::NoInputs);
781        }
782
783        interactive_single_select(
784            editor,
785            state,
786            "Unpin",
787            "Select input",
788            input_ids,
789            |id| {
790                let mut updater = updater(editor, inputs.clone());
791                updater
792                    .unpin_input(id)
793                    .map_err(CommandError::InputNotPinnable)?;
794                Ok((updater.get_changes(), ()))
795            },
796            |id, ()| println!("Unpinned input: {}", id),
797        )?;
798    } else {
799        return Err(CommandError::NoId);
800    }
801
802    Ok(())
803}
804
805pub fn list(flake_edit: &mut FlakeEdit, format: &crate::cli::ListFormat) -> Result<()> {
806    let inputs = flake_edit.list();
807    crate::app::handler::list_inputs(inputs, format);
808    Ok(())
809}
810
811/// Handle the `config` subcommand.
812pub fn config(print_default: bool, path: bool) -> Result<()> {
813    use crate::config::{Config, DEFAULT_CONFIG_TOML};
814
815    if print_default {
816        print!("{}", DEFAULT_CONFIG_TOML);
817        return Ok(());
818    }
819
820    if path {
821        // Show where config would be loaded from
822        let project_path = Config::project_config_path();
823        let user_path = Config::user_config_path();
824
825        if let Some(path) = &project_path {
826            println!("Project config: {}", path.display());
827        }
828        if let Some(path) = &user_path {
829            println!("User config: {}", path.display());
830        }
831
832        if project_path.is_none() && user_path.is_none() {
833            if let Some(user_dir) = Config::user_config_dir() {
834                println!("No config found. Create one at:");
835                println!("  Project: flake-edit.toml (in current directory)");
836                println!("  User:    {}/config.toml", user_dir.display());
837            } else {
838                println!("No config found. Create flake-edit.toml in current directory.");
839            }
840        }
841        return Ok(());
842    }
843
844    Ok(())
845}
846
847/// Manually add a single follows declaration.
848pub fn add_follow(
849    editor: &Editor,
850    flake_edit: &mut FlakeEdit,
851    state: &AppState,
852    input: Option<String>,
853    target: Option<String>,
854) -> Result<()> {
855    let change = if let (Some(input_val), Some(target_val)) = (input.clone(), target) {
856        // Both provided - non-interactive
857        Change::Follows {
858            input: input_val.into(),
859            target: target_val,
860        }
861    } else if state.interactive {
862        // Interactive mode
863        let Some(ctx) = load_follow_context(flake_edit, state)? else {
864            return Ok(());
865        };
866        let top_level_vec: Vec<String> = ctx.top_level_inputs.into_iter().collect();
867
868        let tui_app = if let Some(input_val) = input {
869            tui::App::follow_target("Follow", editor.text(), input_val, top_level_vec)
870        } else {
871            tui::App::follow("Follow", editor.text(), ctx.nested_inputs, top_level_vec)
872        };
873
874        let Some(tui::AppResult::Change(tui_change)) = tui::run(tui_app)? else {
875            return Ok(());
876        };
877        tui_change
878    } else {
879        return Err(CommandError::NoId);
880    };
881
882    apply_change(editor, flake_edit, state, change)
883}
884
885/// Strip surrounding double-quotes from a Nix attribute name.
886///
887/// AST-extracted identifiers may carry quotes (e.g. `"nixpkgs"`), while
888/// lock-file paths only quote names that contain dots.  Stripping quotes
889/// before comparison prevents false mismatches.
890fn strip_attr_quotes(s: &str) -> &str {
891    s.strip_prefix('"')
892        .and_then(|s| s.strip_suffix('"'))
893        .unwrap_or(s)
894}
895
896/// Normalise a `parent.nested` path into the canonical form used by the
897/// lock file: each segment is quoted only when it contains a dot.
898fn normalize_nested_path(path: &str) -> String {
899    let (parent, child) = split_quoted_path(path).unwrap_or((path, path));
900    let p = strip_attr_quotes(parent);
901    let c = strip_attr_quotes(child);
902    let fmt_segment = |s: &str| -> String {
903        if s.contains('.') {
904            format!("\"{}\"", s)
905        } else {
906            s.to_string()
907        }
908    };
909    format!("{}.{}", fmt_segment(p), fmt_segment(c))
910}
911
912/// Collect follows declarations that reference nested inputs
913/// no longer present in the lock file.
914fn collect_stale_follows(
915    inputs: &InputMap,
916    existing_nested_paths: &HashSet<String>,
917) -> Vec<String> {
918    let normalized_existing: HashSet<String> = existing_nested_paths
919        .iter()
920        .map(|p| normalize_nested_path(p))
921        .collect();
922    let mut stale = Vec::new();
923    for (input_id, input) in inputs {
924        for follows in input.follows() {
925            if let Follows::Indirect(nested_name, _target) = follows {
926                let nested_path = format!("{}.{}", input_id, nested_name);
927                if !normalized_existing.contains(&normalize_nested_path(&nested_path)) {
928                    stale.push(nested_path);
929                }
930            }
931        }
932    }
933    stale
934}
935
936/// Automatically follow inputs based on lockfile information.
937///
938/// For each nested input (e.g., "crane.nixpkgs"), if there's a matching
939/// top-level input with the same name (e.g., "nixpkgs"), create a follows
940/// relationship. Skips inputs that already have follows set.
941/// Also removes stale follows declarations that reference nested inputs
942/// no longer present in the lock file.
943///
944/// The config file controls behavior:
945/// - `follow.ignore`: List of input names to skip
946/// - `follow.aliases`: Map of canonical names to alternatives (e.g., nixpkgs = ["nixpkgs-lib"])
947/// - `follow.transitive_min`: Minimum number of matching transitive follows before adding a
948///   top-level follows input (set to 0 to disable)
949pub fn follow_auto(editor: &Editor, flake_edit: &mut FlakeEdit, state: &AppState) -> Result<()> {
950    follow_auto_impl(editor, flake_edit, state, false)
951}
952
953/// Internal implementation with quiet flag for batch processing.
954fn follow_auto_impl(
955    editor: &Editor,
956    flake_edit: &mut FlakeEdit,
957    state: &AppState,
958    quiet: bool,
959) -> Result<()> {
960    let Some(ctx) = load_follow_context(flake_edit, state)? else {
961        if !quiet {
962            println!("Nothing to deduplicate.");
963        }
964        return Ok(());
965    };
966
967    let existing_nested_paths: HashSet<String> = load_flake_lock(state)
968        .map(|l| l.nested_input_paths().into_iter().collect())
969        .unwrap_or_default();
970
971    let to_unfollow = collect_stale_follows(&ctx.inputs, &existing_nested_paths);
972
973    let follow_config = &state.config.follow;
974    let existing_follows = collect_existing_follows(&ctx.inputs);
975    let transitive_min = follow_config.transitive_min();
976    let mut seen_nested: HashSet<String> = HashSet::new();
977
978    // Collect candidates: nested inputs that match a top-level input
979    let mut to_follow: Vec<(String, String)> = ctx
980        .nested_inputs
981        .iter()
982        .filter_map(|nested| {
983            let (parent, nested_name) =
984                split_quoted_path(&nested.path).unwrap_or((&nested.path, &nested.path));
985
986            // Skip ignored inputs (supports both full path and simple name)
987            if follow_config.is_ignored(&nested.path, nested_name) {
988                tracing::debug!("Skipping {}: ignored by config", nested.path);
989                return None;
990            }
991
992            // Skip if already configured in flake.nix
993            if existing_follows.contains(&normalize_nested_path(&nested.path)) {
994                tracing::debug!("Skipping {}: already follows in flake.nix", nested.path);
995                return None;
996            }
997
998            // Find matching top-level input (direct match or via alias)
999            let matching_top_level = ctx
1000                .top_level_inputs
1001                .iter()
1002                .find(|top| follow_config.can_follow(nested_name, top));
1003
1004            let target = matching_top_level?;
1005
1006            // Skip if target already follows from parent (would create cycle)
1007            // e.g., treefmt-nix.follows = "clan-core/treefmt-nix" means we can't
1008            // add clan-core.inputs.treefmt-nix.follows = "treefmt-nix"
1009            if let Some(target_input) = ctx.inputs.get(target.as_str())
1010                && is_follows_reference_to_parent(target_input.url(), parent)
1011            {
1012                tracing::debug!(
1013                    "Skipping {} -> {}: would create cycle (target follows {}/...)",
1014                    nested.path,
1015                    target,
1016                    parent
1017                );
1018                return None;
1019            }
1020
1021            Some((nested.path.clone(), target.clone()))
1022        })
1023        .collect();
1024
1025    for (nested_path, _target) in &to_follow {
1026        seen_nested.insert(nested_path.clone());
1027    }
1028
1029    let mut transitive_groups: HashMap<String, HashMap<String, Vec<String>>> = HashMap::new();
1030
1031    if transitive_min > 0 {
1032        for nested in ctx.nested_inputs.iter() {
1033            let nested_name = nested.path.split('.').next_back().unwrap_or(&nested.path);
1034            let parent = nested.path.split('.').next().unwrap_or(&nested.path);
1035
1036            if follow_config.is_ignored(&nested.path, nested_name) {
1037                continue;
1038            }
1039
1040            if existing_follows.contains(&normalize_nested_path(&nested.path))
1041                || seen_nested.contains(&nested.path)
1042            {
1043                continue;
1044            }
1045
1046            let matching_top_level = ctx
1047                .top_level_inputs
1048                .iter()
1049                .find(|top| follow_config.can_follow(nested_name, top));
1050
1051            if matching_top_level.is_some() {
1052                continue;
1053            }
1054
1055            let Some(transitive_target) = nested.follows.as_ref() else {
1056                continue;
1057            };
1058
1059            // Only consider transitive follows (path with a parent segment).
1060            if !transitive_target.contains('.') {
1061                continue;
1062            }
1063
1064            // Avoid self-follow situations.
1065            if transitive_target == nested_name {
1066                continue;
1067            }
1068
1069            let top_level_name = follow_config
1070                .resolve_alias(nested_name)
1071                .unwrap_or(nested_name)
1072                .to_string();
1073
1074            // Skip if a top-level input already exists with that name.
1075            if ctx.top_level_inputs.contains(&top_level_name) {
1076                continue;
1077            }
1078
1079            // Skip if target already follows from parent (would create cycle)
1080            if let Some(target_input) = ctx.inputs.get(transitive_target.as_str())
1081                && is_follows_reference_to_parent(target_input.url(), parent)
1082            {
1083                continue;
1084            }
1085
1086            transitive_groups
1087                .entry(top_level_name)
1088                .or_default()
1089                .entry(transitive_target.clone())
1090                .or_default()
1091                .push(nested.path.clone());
1092        }
1093    }
1094
1095    // Pass 2b: Group Direct references (follows: None) by canonical name.
1096    // These are nested inputs that point to separate lock nodes rather than
1097    // following an existing path. When multiple parents share the same
1098    // dependency (e.g., treefmt.nixpkgs and treefmt-nix.nixpkgs), we can
1099    // promote one to top-level and have the others follow it.
1100    // Each entry: canonical_name -> Vec<(path, url)>
1101    let mut direct_groups: HashMap<String, Vec<(String, Option<String>)>> = HashMap::new();
1102
1103    if transitive_min > 0 {
1104        for nested in ctx.nested_inputs.iter() {
1105            let nested_name = nested.path.split('.').next_back().unwrap_or(&nested.path);
1106
1107            if nested.follows.is_some() {
1108                continue;
1109            }
1110
1111            if follow_config.is_ignored(&nested.path, nested_name) {
1112                continue;
1113            }
1114
1115            if existing_follows.contains(&normalize_nested_path(&nested.path))
1116                || seen_nested.contains(&nested.path)
1117            {
1118                continue;
1119            }
1120
1121            let matching_top_level = ctx
1122                .top_level_inputs
1123                .iter()
1124                .find(|top| follow_config.can_follow(nested_name, top));
1125
1126            if matching_top_level.is_some() {
1127                continue;
1128            }
1129
1130            let canonical_name = follow_config
1131                .resolve_alias(nested_name)
1132                .unwrap_or(nested_name)
1133                .to_string();
1134
1135            if ctx.top_level_inputs.contains(&canonical_name) {
1136                continue;
1137            }
1138
1139            direct_groups
1140                .entry(canonical_name)
1141                .or_default()
1142                .push((nested.path.clone(), nested.url.clone()));
1143        }
1144    }
1145
1146    let mut toplevel_follows: Vec<(String, String)> = Vec::new();
1147    let mut toplevel_adds: Vec<(String, String)> = Vec::new();
1148
1149    if transitive_min > 0 {
1150        for (top_name, targets) in transitive_groups {
1151            let mut eligible: Vec<(String, Vec<String>)> = targets
1152                .into_iter()
1153                .filter(|(_, paths)| paths.len() >= transitive_min)
1154                .collect();
1155
1156            if eligible.len() != 1 {
1157                continue;
1158            }
1159
1160            let (target_path, paths) = eligible.pop().unwrap();
1161            let follow_target = lock_follows_to_flake_target(&target_path);
1162
1163            if follow_target == top_name {
1164                continue;
1165            }
1166
1167            toplevel_follows.push((top_name.clone(), follow_target));
1168
1169            for path in paths {
1170                if seen_nested.insert(path.clone()) {
1171                    to_follow.push((path, top_name.clone()));
1172                }
1173            }
1174        }
1175
1176        // Promote Direct reference groups: add a new top-level input with the
1177        // URL from one of the nested references, then have all paths follow it.
1178        // Only promote if at least one follows can actually be applied.
1179        let mut direct_groups_sorted: Vec<_> = direct_groups.into_iter().collect();
1180        direct_groups_sorted.sort_by(|a, b| a.0.cmp(&b.0));
1181        for (canonical_name, mut entries) in direct_groups_sorted {
1182            if entries.len() < transitive_min {
1183                continue;
1184            }
1185
1186            entries.sort_by(|a, b| a.0.cmp(&b.0));
1187
1188            let url = entries.iter().find_map(|(_, u)| u.clone());
1189            let Some(url) = url else {
1190                continue;
1191            };
1192
1193            // Dry-run: check that at least one follows can be applied.
1194            let can_follow = entries.iter().any(|(path, _)| {
1195                let change = Change::Follows {
1196                    input: path.clone().into(),
1197                    target: canonical_name.clone(),
1198                };
1199                FlakeEdit::from_text(&editor.text())
1200                    .ok()
1201                    .and_then(|mut fe| fe.apply_change(change).ok().flatten())
1202                    .is_some()
1203            });
1204            if !can_follow {
1205                continue;
1206            }
1207
1208            toplevel_adds.push((canonical_name.clone(), url));
1209
1210            for (path, _) in &entries {
1211                if seen_nested.insert(path.clone()) {
1212                    to_follow.push((path.clone(), canonical_name.clone()));
1213                }
1214            }
1215        }
1216    }
1217
1218    if to_follow.is_empty()
1219        && to_unfollow.is_empty()
1220        && toplevel_follows.is_empty()
1221        && toplevel_adds.is_empty()
1222    {
1223        if !quiet {
1224            println!("All inputs are already deduplicated.");
1225        }
1226        return Ok(());
1227    }
1228
1229    // Apply all changes in memory
1230    let mut current_text = editor.text();
1231    let mut applied: Vec<(&str, &str)> = Vec::new();
1232
1233    // First, add new top-level inputs (from Direct reference promotion).
1234    // These must be added before follows declarations that reference them.
1235    for (id, url) in &toplevel_adds {
1236        let change = Change::Add {
1237            id: Some(id.clone()),
1238            uri: Some(url.clone()),
1239            flake: true,
1240        };
1241
1242        let mut temp_flake_edit =
1243            FlakeEdit::from_text(&current_text).map_err(CommandError::FlakeEdit)?;
1244
1245        match temp_flake_edit.apply_change(change) {
1246            Ok(Some(resulting_text)) => {
1247                let validation = validate::validate(&resulting_text);
1248                if validation.is_ok() {
1249                    current_text = resulting_text;
1250                } else {
1251                    for err in validation.errors {
1252                        eprintln!("Error adding top-level input {}: {}", id, err);
1253                    }
1254                }
1255            }
1256            Ok(None) => eprintln!("Could not add top-level input {}", id),
1257            Err(e) => eprintln!("Error adding top-level input {}: {}", id, e),
1258        }
1259    }
1260
1261    let mut follow_changes: Vec<(String, String)> = Vec::new();
1262    follow_changes.extend(toplevel_follows);
1263    follow_changes.extend(to_follow);
1264
1265    for (input_path, target) in &follow_changes {
1266        let change = Change::Follows {
1267            input: input_path.clone().into(),
1268            target: target.clone(),
1269        };
1270
1271        let mut temp_flake_edit =
1272            FlakeEdit::from_text(&current_text).map_err(CommandError::FlakeEdit)?;
1273
1274        match temp_flake_edit.apply_change(change) {
1275            Ok(Some(resulting_text)) => {
1276                if resulting_text == current_text {
1277                    // No-op: follows already exists with the same target
1278                    continue;
1279                }
1280                let validation = validate::validate(&resulting_text);
1281                if validation.is_ok() {
1282                    current_text = resulting_text;
1283                    applied.push((input_path, target));
1284                } else {
1285                    for err in validation.errors {
1286                        eprintln!("Error applying follows for {}: {}", input_path, err);
1287                    }
1288                }
1289            }
1290            Ok(None) => eprintln!("Could not create follows for {}", input_path),
1291            Err(e) => eprintln!("Error applying follows for {}: {}", input_path, e),
1292        }
1293    }
1294
1295    let mut unfollowed: Vec<&str> = Vec::new();
1296
1297    for nested_path in &to_unfollow {
1298        let change = Change::Remove {
1299            ids: vec![nested_path.clone().into()],
1300        };
1301
1302        let mut temp_flake_edit =
1303            FlakeEdit::from_text(&current_text).map_err(CommandError::FlakeEdit)?;
1304
1305        match temp_flake_edit.apply_change(change) {
1306            Ok(Some(resulting_text)) => {
1307                let validation = validate::validate(&resulting_text);
1308                if validation.is_ok() {
1309                    current_text = resulting_text;
1310                    unfollowed.push(nested_path);
1311                }
1312            }
1313            Ok(None) => {}
1314            Err(e) => eprintln!("Error removing stale follows for {}: {}", nested_path, e),
1315        }
1316    }
1317
1318    if applied.is_empty() && unfollowed.is_empty() {
1319        return Ok(());
1320    }
1321
1322    if state.diff {
1323        let original = editor.text();
1324        let diff = crate::diff::Diff::new(&original, &current_text);
1325        diff.compare();
1326    } else {
1327        editor.apply_or_diff(&current_text, state)?;
1328
1329        if !quiet {
1330            if !applied.is_empty() {
1331                println!(
1332                    "Deduplicated {} {}.",
1333                    applied.len(),
1334                    if applied.len() == 1 {
1335                        "input"
1336                    } else {
1337                        "inputs"
1338                    }
1339                );
1340                for (input_path, target) in &applied {
1341                    if input_path.contains('.') {
1342                        let (parent, nested_name) =
1343                            split_quoted_path(input_path).unwrap_or((input_path, input_path));
1344                        println!("  {}.{} → {}", parent, nested_name, target);
1345                    } else {
1346                        println!("  {} → {}", input_path, target);
1347                    }
1348                }
1349            }
1350
1351            if !unfollowed.is_empty() {
1352                println!(
1353                    "Removed {} stale follows {}.",
1354                    unfollowed.len(),
1355                    if unfollowed.len() == 1 {
1356                        "declaration"
1357                    } else {
1358                        "declarations"
1359                    }
1360                );
1361                for path in &unfollowed {
1362                    println!("  {} (input no longer exists)", path);
1363                }
1364            }
1365        }
1366    }
1367
1368    Ok(())
1369}
1370
1371/// Process multiple flake files in batch mode.
1372///
1373/// Each file is processed independently with its own Editor/AppState.
1374/// Errors are collected and reported at the end, but processing continues
1375/// for all files. Returns error if any file failed.
1376pub fn follow_auto_batch(
1377    paths: &[std::path::PathBuf],
1378    transitive: Option<usize>,
1379    args: &crate::cli::CliArgs,
1380) -> Result<()> {
1381    use std::path::PathBuf;
1382
1383    let mut errors: Vec<(PathBuf, CommandError)> = Vec::new();
1384
1385    for flake_path in paths {
1386        let lock_path = flake_path
1387            .parent()
1388            .map(|p| p.join("flake.lock"))
1389            .unwrap_or_else(|| PathBuf::from("flake.lock"));
1390
1391        let editor = match Editor::from_path(flake_path.clone()) {
1392            Ok(e) => e,
1393            Err(e) => {
1394                errors.push((flake_path.clone(), e.into()));
1395                continue;
1396            }
1397        };
1398
1399        let mut flake_edit = match editor.create_flake_edit() {
1400            Ok(fe) => fe,
1401            Err(e) => {
1402                errors.push((flake_path.clone(), e.into()));
1403                continue;
1404            }
1405        };
1406
1407        let mut state = match AppState::new(
1408            editor.text(),
1409            flake_path.clone(),
1410            args.config().map(PathBuf::from),
1411        ) {
1412            Ok(s) => s
1413                .with_diff(args.diff())
1414                .with_no_lock(args.no_lock())
1415                .with_interactive(false)
1416                .with_lock_file(Some(lock_path))
1417                .with_no_cache(args.no_cache())
1418                .with_cache_path(args.cache().map(PathBuf::from)),
1419            Err(e) => {
1420                errors.push((flake_path.clone(), e.into()));
1421                continue;
1422            }
1423        };
1424
1425        if let Some(min) = transitive {
1426            state.config.follow.transitive_min = min;
1427        }
1428
1429        if let Err(e) = follow_auto_impl(&editor, &mut flake_edit, &state, true) {
1430            errors.push((flake_path.clone(), e));
1431        }
1432    }
1433
1434    if errors.is_empty() {
1435        Ok(())
1436    } else {
1437        for (path, err) in &errors {
1438            eprintln!("Error processing {}: {}", path.display(), err);
1439        }
1440        // Return the first error
1441        Err(errors.into_iter().next().unwrap().1)
1442    }
1443}
1444
1445fn apply_change(
1446    editor: &Editor,
1447    flake_edit: &mut FlakeEdit,
1448    state: &AppState,
1449    change: Change,
1450) -> Result<()> {
1451    let original_content = flake_edit.source_text();
1452    match flake_edit.apply_change(change.clone()) {
1453        Ok(Some(resulting_change)) => {
1454            if change.is_follows() && resulting_change == original_content {
1455                if let Some(id) = change.id() {
1456                    println!(
1457                        "Already follows: {}.inputs.{}.follows = \"{}\"",
1458                        id.input(),
1459                        id.follows().unwrap_or("?"),
1460                        change.follows_target().unwrap_or(&"?".to_string())
1461                    );
1462                }
1463                return Ok(());
1464            }
1465
1466            let validation = validate::validate(&resulting_change);
1467            if validation.has_errors() {
1468                eprintln!("There are errors in the changes:");
1469                for e in &validation.errors {
1470                    tracing::error!("Error: {e}");
1471                }
1472                eprintln!("{}", resulting_change);
1473                eprintln!("There were errors in the changes, the changes have not been applied.");
1474                std::process::exit(1);
1475            }
1476
1477            editor.apply_or_diff(&resulting_change, state)?;
1478
1479            if !state.diff {
1480                // Cache added entries for future completions
1481                if let Change::Add {
1482                    id: Some(id),
1483                    uri: Some(uri),
1484                    ..
1485                } = &change
1486                {
1487                    let mut cache = crate::cache::Cache::load();
1488                    cache.add_entry(id.clone(), uri.clone());
1489                    if let Err(e) = cache.commit() {
1490                        tracing::debug!("Could not write to cache: {}", e);
1491                    }
1492                }
1493
1494                for msg in change.success_messages() {
1495                    println!("{}", msg);
1496                }
1497            }
1498        }
1499        Err(e) => {
1500            return Err(e.into());
1501        }
1502        Ok(None) => {
1503            if change.is_remove() {
1504                return Err(CommandError::CouldNotRemove(
1505                    change.id().map(|id| id.to_string()).unwrap_or_default(),
1506                ));
1507            }
1508            if change.is_follows() {
1509                let id = change.id().map(|id| id.to_string()).unwrap_or_default();
1510                eprintln!("The follows relationship for {} could not be created.", id);
1511                eprintln!(
1512                    "\nPlease check that the input exists in the flake.nix file.\n\
1513                     Use dot notation: `flake-edit follow <input>.<nested-input> <target>`\n\
1514                     Example: `flake-edit follow rust-overlay.nixpkgs nixpkgs`"
1515                );
1516                std::process::exit(1);
1517            }
1518            println!("Nothing changed.");
1519        }
1520    }
1521
1522    Ok(())
1523}
1524
1525#[cfg(test)]
1526mod tests {
1527    use super::*;
1528
1529    #[test]
1530    fn test_is_follows_reference_to_parent() {
1531        // Test case: treefmt-nix.follows = "clan-core/treefmt-nix"
1532        // The URL would be stored as "\"clan-core/treefmt-nix\""
1533        assert!(is_follows_reference_to_parent(
1534            "\"clan-core/treefmt-nix\"",
1535            "clan-core"
1536        ));
1537
1538        // Also test without surrounding quotes (defensive)
1539        assert!(is_follows_reference_to_parent(
1540            "clan-core/treefmt-nix",
1541            "clan-core"
1542        ));
1543
1544        // Test with different parent
1545        assert!(is_follows_reference_to_parent(
1546            "\"some-input/nixpkgs\"",
1547            "some-input"
1548        ));
1549
1550        // Negative test: regular URL should not match
1551        assert!(!is_follows_reference_to_parent(
1552            "\"github:nixos/nixpkgs\"",
1553            "clan-core"
1554        ));
1555
1556        // Negative test: URL that contains the parent but doesn't start with it
1557        assert!(!is_follows_reference_to_parent(
1558            "\"github:foo/clan-core-utils\"",
1559            "clan-core"
1560        ));
1561
1562        // Negative test: parent name matches but not followed by /
1563        assert!(!is_follows_reference_to_parent(
1564            "\"clan-core-extended\"",
1565            "clan-core"
1566        ));
1567
1568        // Edge case: empty URL
1569        assert!(!is_follows_reference_to_parent("", "clan-core"));
1570
1571        // Edge case: just quotes
1572        assert!(!is_follows_reference_to_parent("\"\"", "clan-core"));
1573    }
1574
1575    #[test]
1576    fn test_strip_attr_quotes() {
1577        assert_eq!(strip_attr_quotes("\"nixpkgs\""), "nixpkgs");
1578        assert_eq!(strip_attr_quotes("nixpkgs"), "nixpkgs");
1579        assert_eq!(strip_attr_quotes("\"hls-1.10\""), "hls-1.10");
1580        assert_eq!(strip_attr_quotes(""), "");
1581    }
1582
1583    #[test]
1584    fn test_normalize_nested_path() {
1585        // Unquoted segments stay unquoted
1586        assert_eq!(normalize_nested_path("crane.nixpkgs"), "crane.nixpkgs");
1587
1588        // Unnecessarily quoted segments get stripped
1589        assert_eq!(
1590            normalize_nested_path("\"home-manager\".nixpkgs"),
1591            "home-manager.nixpkgs"
1592        );
1593        assert_eq!(
1594            normalize_nested_path("\"home-manager\".\"nixpkgs\""),
1595            "home-manager.nixpkgs"
1596        );
1597
1598        // Segments with dots stay quoted
1599        assert_eq!(
1600            normalize_nested_path("\"hls-1.10\".nixpkgs"),
1601            "\"hls-1.10\".nixpkgs"
1602        );
1603    }
1604
1605    #[test]
1606    fn test_collect_stale_follows_quoted_attrs() {
1607        use crate::input::Input;
1608
1609        // Simulate: flake.nix has `"home-manager".inputs.nixpkgs.follows = "nixpkgs"`
1610        // Lock file reports the nested path as `home-manager.nixpkgs`.
1611        let mut inputs = InputMap::new();
1612        let mut hm_input = Input::new("\"home-manager\"".to_string());
1613        hm_input.follows.push(Follows::Indirect(
1614            "nixpkgs".to_string(),
1615            "nixpkgs".to_string(),
1616        ));
1617        inputs.insert("\"home-manager\"".to_string(), hm_input);
1618
1619        // Lock file paths (no unnecessary quotes)
1620        let existing: HashSet<String> = ["home-manager.nixpkgs".to_string()].into_iter().collect();
1621
1622        let stale = collect_stale_follows(&inputs, &existing);
1623        assert!(
1624            stale.is_empty(),
1625            "Expected no stale follows, but got: {:?}",
1626            stale
1627        );
1628    }
1629}