Skip to main content

flake_edit/app/commands/
toggle.rs

1//! `flake-edit toggle`: flip an input between its active URL and a
2//! stored alternate.
3//!
4//! `toggle [INPUT] [REF]`, and any omitted coordinate is inferred. A
5//!
6//! `--remove` deletes the resolved variant's line instead of activating it.
7use std::collections::BTreeMap;
8use std::path::{Path, PathBuf};
9use std::process::Command;
10
11use nix_uri::FlakeRef;
12
13use crate::change::{Change, ChangeId};
14use crate::edit::{FlakeEdit, ToggleState};
15use crate::error::Error as FlakeError;
16
17use super::super::editor::Editor;
18use super::super::error::{RefCandidate, ToggleAction, ToggleCandidate};
19use super::super::state::AppState;
20use super::{ConfirmResult, Error, Result, apply_change, confirm_or_apply, pick_one};
21
22pub fn toggle(
23    editor: &Editor,
24    flake_edit: &mut FlakeEdit,
25    state: &AppState,
26    input: Option<String>,
27    reference: Option<String>,
28    remove: bool,
29) -> Result<()> {
30    let states = flake_edit.toggle_states()?;
31    let flake_dir = flake_dir(state);
32    let res = Resolve {
33        states: &states,
34        flake_dir: &flake_dir,
35        action: if remove {
36            ToggleAction::Remove
37        } else {
38            ToggleAction::Activate
39        },
40    };
41
42    match (input, reference) {
43        (None, None) => toggle_no_args(editor, flake_edit, state, &res),
44        (Some(arg), None) if is_ref_shaped(&arg) => {
45            let ref_arg = RefArg::parse(&arg, res.action)?;
46            toggle_by_ref(editor, flake_edit, state, &res, &ref_arg)
47        }
48        (Some(id), None) => {
49            validate_id(flake_edit, res.states, &id)?;
50            act_within_input(editor, flake_edit, state, &res, &id)
51        }
52        (Some(id), Some(reference)) => {
53            validate_id(flake_edit, res.states, &id)?;
54            let ref_arg = RefArg::parse(&reference, res.action)?;
55            act_on_ref(editor, flake_edit, state, &res, &id, &ref_arg)
56        }
57        (None, Some(_)) => unreachable!("clap fills positional arguments in order"),
58    }
59}
60
61/// Read-only resolution context shared by every invocation form.
62struct Resolve<'a> {
63    /// Toggle surface per input id, from [`FlakeEdit::toggle_states`].
64    states: &'a BTreeMap<String, ToggleState>,
65    /// Directory of the edited `flake.nix`, for relative `path:` variants.
66    flake_dir: &'a Path,
67    /// What to do with the resolved variant.
68    action: ToggleAction,
69}
70
71/// Directory holding the edited `flake.nix`. Relative `path:` variants
72/// stored in the file resolve against it.
73fn flake_dir(state: &AppState) -> PathBuf {
74    match state.flake_path.parent() {
75        Some(parent) if !parent.as_os_str().is_empty() => parent.to_path_buf(),
76        _ => PathBuf::from("."),
77    }
78}
79
80/// Shape-based classification of the single positional argument.
81fn is_ref_shaped(arg: &str) -> bool {
82    arg.contains(':') || arg.contains('/') || arg.starts_with('.') || arg.starts_with('~')
83}
84
85/// The id must parse, name a declared input, and that input must carry a
86/// url binding.
87fn validate_id(
88    flake_edit: &FlakeEdit,
89    states: &BTreeMap<String, ToggleState>,
90    id: &str,
91) -> Result<()> {
92    ChangeId::parse(id).map_err(|source| Error::InvalidInputId {
93        id: id.to_string(),
94        source,
95    })?;
96    if !flake_edit.curr_list().contains_key(id) {
97        return Err(Error::ToggleUnknownInput { id: id.to_string() });
98    }
99    if !states.contains_key(id) {
100        return Err(FlakeError::NoUrlToToggle(id.to_string()).into());
101    }
102    Ok(())
103}
104
105/// A parsed, normalized ref argument.
106struct RefArg {
107    /// Exactly what the user typed.
108    typed: String,
109    /// The form a newly created alternate stores: the typed text, with only
110    /// a `path:` prefix added to bare directories.
111    store_as: String,
112    /// Canonicalized location for path-shaped refs whose directory
113    /// exists. Existence is the typo guard for activation, and the
114    /// ladder's later stages need the directory. A removal target may be
115    /// gone, leaving only the literal spelling to match on.
116    canonical_path: Option<PathBuf>,
117    /// Parsed flake reference for everything else.
118    flake_ref: Option<FlakeRef>,
119}
120
121impl RefArg {
122    fn parse(typed: &str, action: ToggleAction) -> Result<Self> {
123        if let Some(path) = path_part(typed) {
124            let canonical_path = match std::fs::canonicalize(path) {
125                Ok(canonical) => Some(canonical),
126                // A removal may name an alternate whose directory is
127                // gone. Matching falls back to the literal spelling.
128                Err(_) if action == ToggleAction::Remove => None,
129                Err(source) => {
130                    return Err(Error::TogglePathMissing {
131                        path: typed.to_string(),
132                        source,
133                    });
134                }
135            };
136            let store_as = if typed.starts_with("path:") {
137                typed.to_string()
138            } else {
139                format!("path:{typed}")
140            };
141            Ok(Self {
142                typed: typed.to_string(),
143                store_as,
144                canonical_path,
145                flake_ref: None,
146            })
147        } else {
148            let flake_ref: FlakeRef = typed.parse().map_err(|source| Error::InvalidUri {
149                uri: typed.to_string(),
150                source,
151            })?;
152            Ok(Self {
153                typed: typed.to_string(),
154                store_as: typed.to_string(),
155                canonical_path: None,
156                flake_ref: Some(flake_ref),
157            })
158        }
159    }
160}
161
162/// Filesystem part of a path-shaped ref, or `None` for non-path refs.
163fn path_part(typed: &str) -> Option<&str> {
164    if let Some(rest) = typed.strip_prefix("path:") {
165        return Some(rest);
166    }
167    (typed.starts_with('.') || typed.starts_with('/') || typed.starts_with('~')).then_some(typed)
168}
169
170fn variants(state: &ToggleState) -> impl Iterator<Item = &String> {
171    std::iter::once(&state.active).chain(state.alternates.iter())
172}
173
174fn variant_equals(variant: &str, ref_arg: &RefArg, flake_dir: &Path) -> bool {
175    if let Some(canonical) = &ref_arg.canonical_path {
176        return variant_path(variant, flake_dir).is_some_and(|p| p == *canonical);
177    }
178    if let Some(flake_ref) = &ref_arg.flake_ref {
179        return variant
180            .parse::<FlakeRef>()
181            .is_ok_and(|v| v.to_canonical_string() == flake_ref.to_canonical_string());
182    }
183    variant == ref_arg.store_as || variant == ref_arg.typed
184}
185
186/// Canonical filesystem location of a path-style variant. Relative paths
187/// stored in `flake.nix` are relative to the flake's directory.
188fn variant_path(variant: &str, flake_dir: &Path) -> Option<PathBuf> {
189    let raw = variant
190        .strip_prefix("path:")
191        .or_else(|| (variant.starts_with('/') || variant.starts_with('.')).then_some(variant))?;
192    let absolute = if Path::new(raw).is_absolute() {
193        PathBuf::from(raw)
194    } else {
195        flake_dir.join(raw)
196    };
197    std::fs::canonicalize(absolute).ok()
198}
199
200/// `(host, owner, repo)` identity used by the ladder's second stage,
201/// lowercased for comparison.
202#[derive(Debug, Clone, PartialEq, Eq)]
203struct RepoIdentity {
204    host: String,
205    owner: String,
206    repo: String,
207}
208
209impl RepoIdentity {
210    fn new(host: &str, owner: &str, repo: &str) -> Self {
211        Self {
212            host: host.to_lowercase(),
213            owner: owner.to_lowercase(),
214            repo: repo.trim_end_matches(".git").to_lowercase(),
215        }
216    }
217}
218
219/// Identity granularities, tried loosest-last. The looser ones let a fork
220/// checkout whose `origin` is the fork find the input declared with
221/// upstream's owner.
222#[derive(Clone, Copy)]
223enum Granularity {
224    HostOwnerRepo,
225    HostRepo,
226    Repo,
227}
228
229impl Granularity {
230    fn matches(self, a: &RepoIdentity, b: &RepoIdentity) -> bool {
231        match self {
232            Self::HostOwnerRepo => a == b,
233            Self::HostRepo => a.host == b.host && a.repo == b.repo,
234            Self::Repo => a.repo == b.repo,
235        }
236    }
237}
238
239/// Identities the ref itself resolves to: the parsed forge coordinates,
240/// or every configured git remote of a path checkout. A path that is not
241/// a git checkout contributes nothing here.
242fn ref_identities(ref_arg: &RefArg) -> Vec<RepoIdentity> {
243    if let Some(path) = &ref_arg.canonical_path {
244        return git_remote_urls(path)
245            .iter()
246            .filter_map(|url| parse_remote_url(url))
247            .collect();
248    }
249    ref_arg
250        .flake_ref
251        .as_ref()
252        .and_then(flake_ref_identity)
253        .into_iter()
254        .collect()
255}
256
257fn flake_ref_identity(flake_ref: &FlakeRef) -> Option<RepoIdentity> {
258    if let Some(forge) = flake_ref.forge_identity() {
259        return Some(RepoIdentity::new(&forge.domain, &forge.owner, &forge.repo));
260    }
261    match (flake_ref.domain(), flake_ref.owner(), flake_ref.repo()) {
262        (Some(domain), Some(owner), Some(repo)) => Some(RepoIdentity::new(domain, owner, repo)),
263        _ => None,
264    }
265}
266
267fn variant_identity(variant: &str) -> Option<RepoIdentity> {
268    flake_ref_identity(&variant.parse::<FlakeRef>().ok()?)
269}
270
271/// Remote URLs from the checkout's git config. Reading the config keeps
272/// the heuristic local and fast. Failures (no git, not a repo) degrade to
273/// an empty list.
274fn git_remote_urls(dir: &Path) -> Vec<String> {
275    let output = Command::new("git")
276        .arg("-C")
277        .arg(dir)
278        .args(["config", "--get-regexp", r"^remote\..*\.url$"])
279        .output();
280    let Ok(output) = output else {
281        return Vec::new();
282    };
283    if !output.status.success() {
284        return Vec::new();
285    }
286    String::from_utf8_lossy(&output.stdout)
287        .lines()
288        .filter_map(|line| line.split_once(' ').map(|(_, url)| url.trim().to_string()))
289        .collect()
290}
291
292/// Parse a git remote URL to its identity. Handles scp-like syntax
293/// (`git@host:owner/repo.git`) and URL forms. The owner is the path
294/// segment directly before the repo.
295fn parse_remote_url(url: &str) -> Option<RepoIdentity> {
296    let (host, path) = if let Some((_, rest)) = url.split_once("://") {
297        let (authority, path) = rest.split_once('/')?;
298        let host = authority.rsplit('@').next()?.split(':').next()?;
299        (host, path)
300    } else if let Some((authority, path)) = url.split_once(':') {
301        (authority.rsplit('@').next()?, path)
302    } else {
303        return None;
304    };
305    let path = path.trim_end_matches('/').trim_end_matches(".git");
306    let mut segments = path.rsplit('/');
307    let repo = segments.next()?;
308    if repo.is_empty() {
309        return None;
310    }
311    let owner = segments.next().unwrap_or("");
312    Some(RepoIdentity::new(host, owner, repo))
313}
314
315/// Repo or directory name the ref answers to, for the ladder's third
316/// stage.
317fn ref_name(ref_arg: &RefArg) -> Option<String> {
318    if let Some(path) = &ref_arg.canonical_path {
319        return path.file_name().map(|n| n.to_string_lossy().into_owned());
320    }
321    if let Some(flake_ref) = &ref_arg.flake_ref {
322        return flake_ref
323            .repo()
324            .or_else(|| flake_ref.id())
325            .map(|name| name.trim_end_matches(".git").to_string());
326    }
327    // A dead path keeps the basename of its typed spelling.
328    Path::new(path_part(&ref_arg.typed)?)
329        .file_name()
330        .map(|n| n.to_string_lossy().into_owned())
331}
332
333/// The matching ladder of the ref form. The first stage that yields any
334/// candidates decides. Later stages are not consulted.
335fn ladder(
336    states: &BTreeMap<String, ToggleState>,
337    ref_arg: &RefArg,
338    flake_dir: &Path,
339) -> Vec<RefCandidate> {
340    let mut found: Vec<RefCandidate> = states
341        .iter()
342        .filter_map(|(id, state)| {
343            variants(state)
344                .find(|v| variant_equals(v, ref_arg, flake_dir))
345                .map(|variant| RefCandidate {
346                    id: id.clone(),
347                    reason: format!("{variant} is a stored variant"),
348                })
349        })
350        .collect();
351    if !found.is_empty() {
352        return found;
353    }
354
355    let ref_ids = ref_identities(ref_arg);
356    if !ref_ids.is_empty() {
357        let how = if ref_arg.canonical_path.is_some() {
358            "matches a git remote"
359        } else {
360            "matches the repository"
361        };
362        for granularity in [
363            Granularity::HostOwnerRepo,
364            Granularity::HostRepo,
365            Granularity::Repo,
366        ] {
367            let hits: Vec<RefCandidate> = states
368                .iter()
369                .filter_map(|(id, state)| {
370                    variants(state)
371                        .find(|v| {
372                            variant_identity(v).is_some_and(|vid| {
373                                ref_ids.iter().any(|rid| granularity.matches(rid, &vid))
374                            })
375                        })
376                        .map(|variant| RefCandidate {
377                            id: id.clone(),
378                            reason: format!("{variant} {how}"),
379                        })
380                })
381                .collect();
382            if !hits.is_empty() {
383                return hits;
384            }
385        }
386    }
387
388    if let Some(name) = ref_name(ref_arg) {
389        found = states
390            .keys()
391            .filter(|id| **id == name)
392            .map(|id| RefCandidate {
393                id: id.clone(),
394                reason: "the name matches the input id".to_string(),
395            })
396            .collect();
397    }
398    found
399}
400
401/// What activating a ref means for one resolved input.
402enum RefTarget {
403    /// The ref equals a stored alternate, so uncomment it.
404    Activate(String),
405    /// The ref equals the active variant, so flip back to the stored side.
406    ActiveNamed,
407    /// Not stored yet, so create a new alternate.
408    Synthesize(String),
409}
410
411fn target_for_ref(state: &ToggleState, ref_arg: &RefArg, flake_dir: &Path) -> RefTarget {
412    if let Some(alternate) = state
413        .alternates
414        .iter()
415        .find(|v| variant_equals(v, ref_arg, flake_dir))
416    {
417        return RefTarget::Activate(alternate.clone());
418    }
419    if variant_equals(&state.active, ref_arg, flake_dir) {
420        return RefTarget::ActiveNamed;
421    }
422    RefTarget::Synthesize(ref_arg.store_as.clone())
423}
424
425fn toggle_change(id: &str, state: &ToggleState, uri: String) -> Result<Change> {
426    let change_id = ChangeId::parse(id).map_err(|source| Error::InvalidInputId {
427        id: id.to_string(),
428        source,
429    })?;
430    Ok(Change::Toggle {
431        id: change_id,
432        uri,
433        previous: state.active.clone(),
434    })
435}
436
437fn remove_change(id: &str, uri: String, activate: Option<String>) -> Result<Change> {
438    let change_id = ChangeId::parse(id).map_err(|source| Error::InvalidInputId {
439        id: id.to_string(),
440        source,
441    })?;
442    Ok(Change::ToggleRemove {
443        id: change_id,
444        uri,
445        activate,
446    })
447}
448
449/// The change acting on one inactive variant of `id`: activate it, or
450/// revert to it by discarding the currently active url.
451fn within_change(
452    action: ToggleAction,
453    id: &str,
454    toggle_state: &ToggleState,
455    uri: String,
456) -> Result<Change> {
457    match action {
458        ToggleAction::Activate => toggle_change(id, toggle_state, uri),
459        ToggleAction::Remove => remove_change(id, toggle_state.active.clone(), Some(uri)),
460    }
461}
462
463/// Deleting only a comment line cannot change the resolved source, so
464/// such removals skip the lockfile refresh.
465fn effective_state(state: &AppState, change: &Change) -> AppState {
466    let mut state = state.clone();
467    if matches!(change, Change::ToggleRemove { activate: None, .. }) {
468        state.no_lock = true;
469    }
470    state
471}
472
473fn apply_toggle_change(
474    editor: &Editor,
475    flake_edit: &mut FlakeEdit,
476    state: &AppState,
477    change: Change,
478) -> Result<()> {
479    let state = effective_state(state, &change);
480    apply_change(editor, flake_edit, &state, change)
481}
482
483fn toggle_no_args(
484    editor: &Editor,
485    flake_edit: &mut FlakeEdit,
486    state: &AppState,
487    res: &Resolve<'_>,
488) -> Result<()> {
489    let toggleable: Vec<(&String, &ToggleState)> = res
490        .states
491        .iter()
492        .filter(|(_, s)| !s.alternates.is_empty())
493        .collect();
494    match toggleable.as_slice() {
495        [] => Err(Error::NoToggleableInputs),
496        [(id, _)] => act_within_input(editor, flake_edit, state, res, id),
497        _ if state.interactive => pick_input_and_act(editor, flake_edit, state, res, {
498            toggleable
499                .iter()
500                .map(|(id, s)| ((*id).clone(), format!("{id} ({})", s.active)))
501                .collect()
502        }),
503        _ => Err(Error::MultipleToggleableInputs {
504            candidates: toggleable
505                .into_iter()
506                .map(|(id, s)| ToggleCandidate {
507                    id: id.clone(),
508                    variants: variants(s).cloned().collect(),
509                })
510                .collect(),
511        }),
512    }
513}
514
515fn toggle_by_ref(
516    editor: &Editor,
517    flake_edit: &mut FlakeEdit,
518    state: &AppState,
519    res: &Resolve<'_>,
520    ref_arg: &RefArg,
521) -> Result<()> {
522    let candidates = ladder(res.states, ref_arg, res.flake_dir);
523    match candidates.as_slice() {
524        [] => Err(Error::ToggleRefUnmatched {
525            reference: ref_arg.typed.clone(),
526        }),
527        [candidate] => act_on_ref(
528            editor,
529            flake_edit,
530            state,
531            res,
532            &candidate.id.clone(),
533            ref_arg,
534        ),
535        _ if state.interactive => {
536            let items = candidates
537                .iter()
538                .map(|c| (c.id.clone(), format!("{} ({})", c.id, c.reason)))
539                .collect();
540            pick_input_and_act_ref(editor, flake_edit, state, res, items, ref_arg)
541        }
542        _ => Err(Error::ToggleRefAmbiguous {
543            reference: ref_arg.typed.clone(),
544            candidates,
545        }),
546    }
547}
548
549/// Act on `ref_arg` against the already-resolved input `id`.
550///
551/// Activation creates a new alternate when the ref is not yet stored.
552/// Removal refuses to invent one. Naming the active variant flips
553/// to the stored side (and, for removal, deletes the previously active
554/// line instead of keeping it as a comment).
555fn act_on_ref(
556    editor: &Editor,
557    flake_edit: &mut FlakeEdit,
558    state: &AppState,
559    res: &Resolve<'_>,
560    id: &str,
561    ref_arg: &RefArg,
562) -> Result<()> {
563    let toggle_state = &res.states[id];
564    match (
565        target_for_ref(toggle_state, ref_arg, res.flake_dir),
566        res.action,
567    ) {
568        (RefTarget::Activate(uri) | RefTarget::Synthesize(uri), ToggleAction::Activate) => {
569            let change = toggle_change(id, toggle_state, uri)?;
570            apply_toggle_change(editor, flake_edit, state, change)
571        }
572        (RefTarget::Activate(uri), ToggleAction::Remove) => {
573            let change = remove_change(id, uri, None)?;
574            apply_toggle_change(editor, flake_edit, state, change)
575        }
576        (RefTarget::Synthesize(_), ToggleAction::Remove) => Err(Error::ToggleRemoveUnstored {
577            reference: ref_arg.typed.clone(),
578            id: id.to_string(),
579            active: toggle_state.active.clone(),
580            alternates: toggle_state.alternates.clone(),
581        }),
582        (RefTarget::ActiveNamed, ToggleAction::Activate) => {
583            match toggle_state.alternates.as_slice() {
584                [] => Err(Error::ToggleAlreadyActive {
585                    reference: ref_arg.typed.clone(),
586                    id: id.to_string(),
587                }),
588                _ => act_within_input(editor, flake_edit, state, res, id),
589            }
590        }
591        (RefTarget::ActiveNamed, ToggleAction::Remove) => {
592            match toggle_state.alternates.as_slice() {
593                [] => Err(Error::ToggleRemoveActive {
594                    reference: ref_arg.typed.clone(),
595                    id: id.to_string(),
596                }),
597                [only] => {
598                    let change =
599                        remove_change(id, toggle_state.active.clone(), Some(only.clone()))?;
600                    apply_toggle_change(editor, flake_edit, state, change)
601                }
602                _ if state.interactive => {
603                    pick_replacement_and_remove(editor, flake_edit, state, id, toggle_state)
604                }
605                _ => Err(Error::ToggleAmbiguousVariant {
606                    id: id.to_string(),
607                    active: toggle_state.active.clone(),
608                    alternates: toggle_state.alternates.clone(),
609                    action: ToggleAction::Activate,
610                }),
611            }
612        }
613    }
614}
615
616/// Act on `id`'s stored side: one alternate flips (or is deleted)
617/// silently, several open the variant picker (or error
618/// non-interactively), none is an error.
619fn act_within_input(
620    editor: &Editor,
621    flake_edit: &mut FlakeEdit,
622    state: &AppState,
623    res: &Resolve<'_>,
624    id: &str,
625) -> Result<()> {
626    let toggle_state = &res.states[id];
627    match toggle_state.alternates.as_slice() {
628        [] => Err(Error::ToggleNoAlternate { id: id.to_string() }),
629        [only] => {
630            let change = within_change(res.action, id, toggle_state, only.clone())?;
631            apply_toggle_change(editor, flake_edit, state, change)
632        }
633        _ if state.interactive => match res.action {
634            ToggleAction::Activate => {
635                pick_variant_and_act(editor, flake_edit, state, res.action, id, toggle_state)
636            }
637            ToggleAction::Remove => {
638                pick_replacement_and_remove(editor, flake_edit, state, id, toggle_state)
639            }
640        },
641        _ => Err(Error::ToggleAmbiguousVariant {
642            id: id.to_string(),
643            active: toggle_state.active.clone(),
644            alternates: toggle_state.alternates.clone(),
645            action: ToggleAction::Activate,
646        }),
647    }
648}
649
650/// Apply `change` through the confirm-with-diff screen, printing the
651/// success line once applied.
652fn confirm_toggle(
653    editor: &Editor,
654    flake_edit: &mut FlakeEdit,
655    state: &AppState,
656    change: Change,
657    show_diff: bool,
658) -> Result<Option<bool>> {
659    let state = effective_state(state, &change);
660    let outcome = flake_edit.apply_change(change.clone())?;
661    let Some(text) = outcome.text else {
662        println!("Nothing changed.");
663        return Ok(Some(true));
664    };
665    match confirm_or_apply(editor, &state, "Toggle", &text, show_diff)? {
666        ConfirmResult::Applied => {
667            for msg in change.success_messages() {
668                println!("{msg}");
669            }
670            Ok(Some(true))
671        }
672        ConfirmResult::Back => Ok(Some(false)),
673        ConfirmResult::Cancelled => Ok(None),
674    }
675}
676
677/// Variant picker for one input: fuzzy-select among the inactive
678/// variants, with the active one shown in the prompt for context.
679fn pick_variant_and_act(
680    editor: &Editor,
681    flake_edit: &mut FlakeEdit,
682    state: &AppState,
683    action: ToggleAction,
684    id: &str,
685    toggle_state: &ToggleState,
686) -> Result<()> {
687    let prompt = match action {
688        ToggleAction::Activate => format!("Select variant (active: {})", toggle_state.active),
689        ToggleAction::Remove => {
690            format!("Select variant to remove (active: {})", toggle_state.active)
691        }
692    };
693    loop {
694        let Some((uri, show_diff)) =
695            pick_one(state, "Toggle", &prompt, toggle_state.alternates.clone())?
696        else {
697            return Ok(());
698        };
699        let change = within_change(action, id, toggle_state, uri)?;
700        match confirm_toggle(editor, flake_edit, state, change, show_diff)? {
701            Some(true) | None => return Ok(()),
702            Some(false) => continue,
703        }
704    }
705}
706
707/// Replacement picker for removing the active url: fuzzy-select the
708/// alternate that takes its place.
709fn pick_replacement_and_remove(
710    editor: &Editor,
711    flake_edit: &mut FlakeEdit,
712    state: &AppState,
713    id: &str,
714    toggle_state: &ToggleState,
715) -> Result<()> {
716    let prompt = format!("Select replacement (removing: {})", toggle_state.active);
717    loop {
718        let Some((uri, show_diff)) =
719            pick_one(state, "Toggle", &prompt, toggle_state.alternates.clone())?
720        else {
721            return Ok(());
722        };
723        let change = remove_change(id, toggle_state.active.clone(), Some(uri))?;
724        match confirm_toggle(editor, flake_edit, state, change, show_diff)? {
725            Some(true) | None => return Ok(()),
726            Some(false) => continue,
727        }
728    }
729}
730
731/// Input picker for the no-argument form: fuzzy-select among the
732/// toggleable inputs, then continue per the variant rules.
733fn pick_input_and_act(
734    editor: &Editor,
735    flake_edit: &mut FlakeEdit,
736    state: &AppState,
737    res: &Resolve<'_>,
738    items: Vec<(String, String)>,
739) -> Result<()> {
740    let by_item: BTreeMap<String, String> = items
741        .iter()
742        .map(|(id, item)| (item.clone(), id.clone()))
743        .collect();
744    loop {
745        let Some((item, show_diff)) = pick_one(
746            state,
747            "Toggle",
748            "Select input",
749            items.iter().map(|(_, item)| item.clone()).collect(),
750        )?
751        else {
752            return Ok(());
753        };
754        let id = &by_item[&item];
755        let toggle_state = &res.states[id];
756        match toggle_state.alternates.as_slice() {
757            [only] => {
758                let change = within_change(res.action, id, toggle_state, only.clone())?;
759                match confirm_toggle(editor, flake_edit, state, change, show_diff)? {
760                    Some(true) | None => return Ok(()),
761                    Some(false) => continue,
762                }
763            }
764            _ => {
765                return match res.action {
766                    ToggleAction::Activate => pick_variant_and_act(
767                        editor,
768                        flake_edit,
769                        state,
770                        res.action,
771                        id,
772                        toggle_state,
773                    ),
774                    // Mirror the bare-id path: removal picks the
775                    // replacement for the discarded active url.
776                    ToggleAction::Remove => {
777                        pick_replacement_and_remove(editor, flake_edit, state, id, toggle_state)
778                    }
779                };
780            }
781        }
782    }
783}
784
785/// Input picker for an ambiguous ref: fuzzy-select among the ladder's
786/// candidates, then act on the ref against the chosen input.
787fn pick_input_and_act_ref(
788    editor: &Editor,
789    flake_edit: &mut FlakeEdit,
790    state: &AppState,
791    res: &Resolve<'_>,
792    items: Vec<(String, String)>,
793    ref_arg: &RefArg,
794) -> Result<()> {
795    let by_item: BTreeMap<String, String> = items
796        .iter()
797        .map(|(id, item)| (item.clone(), id.clone()))
798        .collect();
799    let Some((item, _show_diff)) = pick_one(
800        state,
801        "Toggle",
802        "Select input",
803        items.iter().map(|(_, item)| item.clone()).collect(),
804    )?
805    else {
806        return Ok(());
807    };
808    let id = by_item[&item].clone();
809    act_on_ref(editor, flake_edit, state, res, &id, ref_arg)
810}
811
812#[cfg(test)]
813mod tests {
814    use super::*;
815
816    #[test]
817    fn ref_shape_classification() {
818        for ref_like in [
819            "github:owner/repo",
820            "../rust-overlay",
821            "./checkout",
822            "~/dev/overlay",
823            "/abs/path",
824            "owner/repo",
825            "path:../x",
826        ] {
827            assert!(is_ref_shaped(ref_like), "{ref_like} should be a ref");
828        }
829        for id_like in ["rust-overlay", "nixpkgs", "crane"] {
830            assert!(!is_ref_shaped(id_like), "{id_like} should be an id");
831        }
832    }
833
834    #[test]
835    fn remote_url_parsing_covers_common_forms() {
836        let cases = [
837            (
838                "https://github.com/oxalica/rust-overlay.git",
839                ("github.com", "oxalica", "rust-overlay"),
840            ),
841            (
842                "git@github.com:oxalica/rust-overlay.git",
843                ("github.com", "oxalica", "rust-overlay"),
844            ),
845            (
846                "ssh://git@github.com/oxalica/rust-overlay",
847                ("github.com", "oxalica", "rust-overlay"),
848            ),
849            (
850                "https://gitlab.com/group/subgroup/repo",
851                ("gitlab.com", "subgroup", "repo"),
852            ),
853        ];
854        for (url, (host, owner, repo)) in cases {
855            let identity = parse_remote_url(url).expect(url);
856            assert_eq!(identity, RepoIdentity::new(host, owner, repo), "{url}");
857        }
858        assert!(parse_remote_url("not-a-url").is_none());
859    }
860
861    #[test]
862    fn forge_ref_identity_resolves_canonical_host() {
863        let identity = variant_identity("github:NixOS/nixpkgs/nixos-unstable").unwrap();
864        assert_eq!(
865            identity,
866            RepoIdentity::new("github.com", "nixos", "nixpkgs")
867        );
868    }
869
870    #[test]
871    fn granularities_loosen_in_order() {
872        let declared = RepoIdentity::new("github.com", "oxalica", "rust-overlay");
873        let fork = RepoIdentity::new("github.com", "a-kenji", "rust-overlay");
874        let elsewhere = RepoIdentity::new("git.example.org", "mirror", "rust-overlay");
875        assert!(!Granularity::HostOwnerRepo.matches(&fork, &declared));
876        assert!(Granularity::HostRepo.matches(&fork, &declared));
877        assert!(!Granularity::HostRepo.matches(&elsewhere, &declared));
878        assert!(Granularity::Repo.matches(&elsewhere, &declared));
879    }
880}