1use 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
61struct Resolve<'a> {
63 states: &'a BTreeMap<String, ToggleState>,
65 flake_dir: &'a Path,
67 action: ToggleAction,
69}
70
71fn 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
80fn is_ref_shaped(arg: &str) -> bool {
82 arg.contains(':') || arg.contains('/') || arg.starts_with('.') || arg.starts_with('~')
83}
84
85fn 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
105struct RefArg {
107 typed: String,
109 store_as: String,
112 canonical_path: Option<PathBuf>,
117 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 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
162fn 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
186fn 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#[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#[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
239fn 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
271fn 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
292fn 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
315fn 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 Path::new(path_part(&ref_arg.typed)?)
329 .file_name()
330 .map(|n| n.to_string_lossy().into_owned())
331}
332
333fn 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
401enum RefTarget {
403 Activate(String),
405 ActiveNamed,
407 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
449fn 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
463fn 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
549fn 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
616fn 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
650fn 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
677fn 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
707fn 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
731fn 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 ToggleAction::Remove => {
777 pick_replacement_and_remove(editor, flake_edit, state, id, toggle_state)
778 }
779 };
780 }
781 }
782 }
783}
784
785fn 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}