1use serde::Serialize;
11
12use super::fragments::{self, Anchor, Fragment, Tokens};
13use super::source::Source;
14use super::target::Target;
15use super::version::Resolved;
16use super::{Channel, Kind, Manager};
17use crate::error::RkError;
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
21#[serde(rename_all = "kebab-case")]
22pub enum Mode {
23 Fragment,
25 Native,
27 Manual,
29}
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum Support {
34 Fragment,
36 Manual(&'static str),
38}
39
40#[derive(Debug, Clone, Serialize)]
42pub struct Seed {
43 pub file: String,
45 pub text: String,
47}
48
49#[derive(Debug, Clone, Serialize)]
51pub struct Recommendation {
52 pub kind: Kind,
54 #[serde(skip_serializing_if = "Option::is_none")]
56 pub manager: Option<Manager>,
57 pub manager_present: bool,
59 pub channel: Channel,
61 pub mode: Mode,
63 #[serde(skip_serializing_if = "Option::is_none")]
65 pub file: Option<String>,
66 pub fragments: Vec<Fragment>,
68 #[serde(skip_serializing_if = "Option::is_none")]
70 pub seed: Option<Seed>,
71 #[serde(skip_serializing_if = "Option::is_none")]
73 pub command: Option<String>,
74 #[serde(skip_serializing_if = "Option::is_none")]
76 pub reason: Option<&'static str>,
77 pub freshness: String,
79}
80
81#[must_use]
83pub const fn support(manager: Manager, channel: Channel) -> Support {
84 match (manager, channel) {
85 (Manager::Flake | Manager::Devbox, Channel::Flake)
86 | (
87 Manager::Mise,
88 Channel::Crates | Channel::Pypi | Channel::Npm | Channel::GithubRelease,
89 ) => Support::Fragment,
90 (Manager::Flake, Channel::GithubRelease) => Support::Manual("network-hash-needed"),
91 (Manager::Flake | Manager::Devbox, _) => Support::Manual("nixpkgs-attribute-unknown"),
92 (Manager::Mise, Channel::Flake) => Support::Manual("no-mise-flake-backend"),
93 (Manager::Asdf, _) => Support::Manual("asdf-plugin-unknown"),
94 }
95}
96
97#[must_use]
99pub const fn preference(manager: Manager) -> [Channel; 5] {
100 match manager {
101 Manager::Flake | Manager::Devbox => [
102 Channel::Flake,
103 Channel::Crates,
104 Channel::GithubRelease,
105 Channel::Pypi,
106 Channel::Npm,
107 ],
108 Manager::Mise | Manager::Asdf => [
109 Channel::Crates,
110 Channel::GithubRelease,
111 Channel::Pypi,
112 Channel::Npm,
113 Channel::Flake,
114 ],
115 }
116}
117
118#[must_use]
120pub fn recommend(
121 source: &Source,
122 target: &Target,
123 kind: Kind,
124 resolved: &Resolved,
125) -> Vec<Recommendation> {
126 match kind {
127 Kind::Dev => dev_options(source, target, resolved),
128 Kind::Prod => prod_option(source, target, resolved).into_iter().collect(),
129 }
130}
131
132pub fn choose(
139 options: &[Recommendation],
140 manager: Option<Manager>,
141 channel: Option<Channel>,
142) -> Result<&Recommendation, RkError> {
143 let Some(first) = options.first() else {
144 return Err(RkError::Usage(
145 "the source declares no distribution channel; nothing can land".into(),
146 ));
147 };
148 if first.kind == Kind::Prod {
149 return Ok(first);
150 }
151 let manager = match manager {
152 Some(manager) => manager,
153 None => detected_manager(options)?,
154 };
155 let viable: Vec<&Recommendation> = options
156 .iter()
157 .filter(|o| o.manager == Some(manager))
158 .collect();
159 if viable.is_empty() && options.iter().any(|o| o.manager_present) {
160 let names: Vec<&str> = options
161 .iter()
162 .filter(|o| o.manager_present)
163 .filter_map(|o| o.manager.map(Manager::as_str))
164 .collect::<std::collections::BTreeSet<_>>()
165 .into_iter()
166 .collect();
167 return Err(RkError::Usage(format!(
168 "the target carries no {} file; its managers are {}, and a second manager for one tool is two pins",
169 manager.as_str(),
170 names.join(", ")
171 )));
172 }
173 let Some(channel) = channel else {
174 return viable.first().copied().ok_or_else(|| {
175 RkError::Usage(format!(
176 "the source offers no channel {} can take",
177 manager.as_str()
178 ))
179 });
180 };
181 viable
182 .iter()
183 .find(|o| o.channel == channel)
184 .copied()
185 .ok_or_else(|| {
186 let names: Vec<&str> = viable.iter().map(|o| o.channel.as_str()).collect();
187 RkError::Usage(format!(
188 "{} is not a channel the source offers for {}; the viable channels are {}",
189 channel.as_str(),
190 manager.as_str(),
191 names.join(", ")
192 ))
193 })
194}
195
196fn detected_manager(options: &[Recommendation]) -> Result<Manager, RkError> {
199 let present: Vec<Manager> = Manager::ALL
200 .into_iter()
201 .filter(|m| {
202 options
203 .iter()
204 .any(|o| o.manager == Some(*m) && o.manager_present)
205 })
206 .collect();
207 match present.as_slice() {
208 [one] => Ok(*one),
209 [] => Err(RkError::Usage(
210 "the target carries no tool manager file; pass --manager to seed one".into(),
211 )),
212 many => {
213 let names: Vec<&str> = many.iter().map(|m| m.as_str()).collect();
214 Err(RkError::Usage(format!(
215 "the target carries {}; pass --manager to choose",
216 names.join(" and ")
217 )))
218 }
219 }
220}
221
222fn tokens(source: &Source, resolved: &Resolved) -> Tokens {
224 let name = source.name.clone().unwrap_or_default();
225 Tokens {
226 input: fragments::nix_input_name(&name),
227 name,
228 version: resolved.version.clone(),
229 tag: resolved.tag.clone(),
230 owner_repo: source.owner_repo.clone(),
231 bin: source.bin().map(str::to_owned),
232 flake_ref: fragments::flake_ref(
233 source.host.as_deref(),
234 source.owner_repo.as_deref(),
235 &resolved.tag,
236 ),
237 tool_line: None,
238 }
239}
240
241fn dev_options(source: &Source, target: &Target, resolved: &Resolved) -> Vec<Recommendation> {
242 let managers: Vec<(Manager, bool)> = if target.managers.is_empty() {
243 Manager::ALL.into_iter().map(|m| (m, false)).collect()
244 } else {
245 target.managers.iter().map(|m| (m.manager, true)).collect()
246 };
247 let tokens = tokens(source, resolved);
248 let mut out = Vec::new();
249 for (manager, present) in managers {
250 let file = target.file_of(manager);
251 let file_name = file.map_or_else(
252 || Target::default_file(manager).to_owned(),
253 |f| f.file.clone(),
254 );
255 let text = file.map(|f| f.text.as_str());
256 for channel in preference(manager) {
257 if !source.has(channel) {
258 continue;
259 }
260 let mut option = Recommendation {
261 kind: Kind::Dev,
262 manager: Some(manager),
263 manager_present: present,
264 channel,
265 mode: Mode::Manual,
266 file: Some(file_name.clone()),
267 fragments: Vec::new(),
268 seed: None,
269 command: None,
270 reason: None,
271 freshness: freshness(manager, channel, &tokens),
272 };
273 match support(manager, channel) {
274 Support::Manual(reason) => {
275 option.reason = Some(reason);
276 if manager == Manager::Asdf {
277 option.fragments = vec![asdf_fragment(&tokens, text)];
278 }
279 }
280 Support::Fragment if channel == Channel::Flake && tokens.flake_ref.is_none() => {
281 option.reason = Some("forge-undetected");
282 }
283 Support::Fragment
284 if manager == Manager::Flake && input_name_taken(&tokens, text) =>
285 {
286 option.reason = Some("flake-input-name-taken");
287 }
288 Support::Fragment => {
289 option.mode = Mode::Fragment;
290 let (fragments, seed) = match manager {
291 Manager::Flake => flake_fragments(&tokens, &file_name, text),
292 Manager::Mise => mise_fragment(channel, &tokens, &file_name, text),
293 Manager::Devbox => devbox_fragment(&tokens, &file_name, text),
294 Manager::Asdf => (Vec::new(), None),
295 };
296 option.fragments = fragments;
297 option.seed = (!present).then_some(seed).flatten();
298 }
299 }
300 out.push(option);
301 }
302 }
303 out
304}
305
306fn prod_option(source: &Source, target: &Target, resolved: &Resolved) -> Option<Recommendation> {
307 let name = source.name.as_deref()?;
308 let tech = target.tech?;
309 let version = &resolved.version;
310 let mut option = Recommendation {
311 kind: Kind::Prod,
312 manager: None,
313 manager_present: false,
314 channel: source.channels.first()?.channel,
315 mode: Mode::Native,
316 file: None,
317 fragments: Vec::new(),
318 seed: None,
319 command: None,
320 reason: None,
321 freshness: String::new(),
322 };
323 let native = match tech {
324 "rust" if source.has(Channel::Crates) => Some((
325 Channel::Crates,
326 format!("cargo add {name}@{version}"),
327 format!("cargo update -p {name}"),
328 )),
329 "python" if source.has(Channel::Pypi) => Some((
330 Channel::Pypi,
331 format!("uv add \"{name}=={version}\""),
332 format!("uv lock --upgrade-package {name}"),
333 )),
334 "node" if source.has(Channel::Npm) => Some((
335 Channel::Npm,
336 format!("npm install {name}@{version}"),
337 format!("npm update {name}"),
338 )),
339 _ => None,
340 };
341 if let Some((channel, command, freshness)) = native {
342 option.channel = channel;
343 option.command = Some(command);
344 option.freshness = freshness;
345 } else {
346 option.mode = Mode::Manual;
347 option.reason = Some("technology-mismatch");
348 }
349 Some(option)
350}
351
352fn freshness(manager: Manager, channel: Channel, tokens: &Tokens) -> String {
353 let name = &tokens.name;
354 match manager {
355 Manager::Flake => format!("nix flake update {}", tokens.input),
356 Manager::Mise => {
357 let id = match channel {
358 Channel::Crates => format!("cargo:{name}"),
359 Channel::Pypi => format!("pipx:{name}"),
360 Channel::Npm => format!("npm:{name}"),
361 Channel::GithubRelease => {
362 format!("ubi:{}", tokens.owner_repo.clone().unwrap_or_default())
363 }
364 Channel::Flake => name.clone(),
365 };
366 format!("mise upgrade --bump {id}")
367 }
368 Manager::Asdf => format!("edit the {name} line in .tool-versions, then asdf install"),
369 Manager::Devbox => "devbox update".to_owned(),
370 }
371}
372
373fn input_name_taken(tokens: &Tokens, text: Option<&str>) -> bool {
378 let Some(text) = text else {
379 return false;
380 };
381 let Some(body) = fragments::input_binding(text, &tokens.input) else {
382 return false;
383 };
384 let ours = tokens
385 .flake_ref
386 .as_deref()
387 .and_then(|reference| reference.rsplit_once('/'))
388 .map_or_else(String::new, |(prefix, _)| format!("{prefix}/"));
389 !body.contains(&ours)
390}
391
392fn flake_fragments(
393 tokens: &Tokens,
394 file: &str,
395 text: Option<&str>,
396) -> (Vec<Fragment>, Option<Seed>) {
397 let input = tokens.input.as_str();
398 let package_prefix = format!("{input}.packages.");
399 let fragments = vec![
400 Fragment {
401 id: "flake-input",
402 file: file.to_owned(),
403 role: "the pinned input",
404 placement: "insert-into-attrset",
405 anchor: Anchor {
406 kind: "attrset",
407 path: "inputs".to_owned(),
408 needle: text.and_then(|t| fragments::first_found(t, &["inputs = {", "inputs ="])),
409 },
410 text: fragments::fragment("depend-flake-input.nix.in", tokens),
411 present: Some(text.is_some_and(|t| fragments::input_binding(t, input).is_some())),
412 },
413 Fragment {
414 id: "outputs-argument",
415 file: file.to_owned(),
416 role: "the input as an argument of the outputs function",
417 placement: "add-to-function-head",
418 anchor: Anchor {
419 kind: "function-head",
420 path: "outputs".to_owned(),
421 needle: text.and_then(|t| fragments::first_found(t, &["outputs =", "outputs"])),
422 },
423 text: fragments::fragment("depend-flake-outputs-arg.nix.in", tokens),
424 present: text.map_or(Some(false), |t| {
425 fragments::outputs_argument_present(t, input)
426 }),
427 },
428 Fragment {
429 id: "devshell-package",
430 file: file.to_owned(),
431 role: "the package in the default devshell",
432 placement: "append-to-list",
433 anchor: Anchor {
434 kind: "list",
435 path: "devShells.<system>.default.packages".to_owned(),
436 needle: text
437 .and_then(|t| fragments::first_found(t, &["packages = [", "devShells"])),
438 },
439 text: fragments::fragment("depend-flake-package.nix.in", tokens),
440 present: text.map_or(Some(false), |t| {
441 if t.contains(&package_prefix) {
442 Some(true)
443 } else {
444 t.contains("devShells").then_some(false)
445 }
446 }),
447 },
448 ];
449 let seed = Seed {
450 file: file.to_owned(),
451 text: fragments::seed("depend-seed-flake.nix.in", tokens),
452 };
453 (fragments, Some(seed))
454}
455
456fn mise_fragment(
457 channel: Channel,
458 tokens: &Tokens,
459 file: &str,
460 text: Option<&str>,
461) -> (Vec<Fragment>, Option<Seed>) {
462 let block = match channel {
463 Channel::GithubRelease => "depend-mise-ubi.toml.in",
464 Channel::Pypi => "depend-mise-pipx.toml.in",
465 Channel::Npm => "depend-mise-npm.toml.in",
466 Channel::Crates | Channel::Flake => "depend-mise-cargo.toml.in",
467 };
468 let line = fragments::fragment(block, tokens);
469 let key = line.split(" = ").next().unwrap_or(&line).to_owned();
470 let seed_tokens = Tokens {
471 tool_line: Some(line.clone()),
472 ..tokens.clone()
473 };
474 let fragment = Fragment {
475 id: "mise-tool",
476 file: file.to_owned(),
477 role: "the pinned tool entry",
478 placement: "insert-into-table",
479 anchor: Anchor {
480 kind: "table",
481 path: "tools".to_owned(),
482 needle: text.and_then(|t| fragments::first_found(t, &["[tools]"])),
483 },
484 text: line,
485 present: Some(text.is_some_and(|t| t.contains(&key))),
486 };
487 let seed = Seed {
488 file: file.to_owned(),
489 text: fragments::seed("depend-seed-mise.toml.in", &seed_tokens),
490 };
491 (vec![fragment], Some(seed))
492}
493
494fn devbox_fragment(
495 tokens: &Tokens,
496 file: &str,
497 text: Option<&str>,
498) -> (Vec<Fragment>, Option<Seed>) {
499 let reference = tokens.flake_ref.clone().unwrap_or_default();
500 let fragment = Fragment {
501 id: "devbox-package",
502 file: file.to_owned(),
503 role: "the flake package entry",
504 placement: "append-to-array",
505 anchor: Anchor {
506 kind: "array",
507 path: "packages".to_owned(),
508 needle: text.and_then(|t| fragments::first_found(t, &["\"packages\""])),
509 },
510 text: fragments::fragment("depend-devbox-flake.json.in", tokens),
511 present: Some(text.is_some_and(|t| t.contains(&reference))),
512 };
513 let seed = Seed {
514 file: file.to_owned(),
515 text: fragments::seed("depend-seed-devbox.json.in", tokens),
516 };
517 (vec![fragment], Some(seed))
518}
519
520fn asdf_fragment(tokens: &Tokens, text: Option<&str>) -> Fragment {
521 let prefix = format!("{} ", tokens.name);
522 Fragment {
523 id: "asdf-line",
524 file: ".tool-versions".to_owned(),
525 role: "the line the plugin would take, once the plugin is known",
526 placement: "append-line",
527 anchor: Anchor {
528 kind: "file",
529 path: ".tool-versions".to_owned(),
530 needle: None,
531 },
532 text: fragments::fragment("depend-asdf-line.in", tokens),
533 present: Some(text.is_some_and(|t| t.lines().any(|l| l.starts_with(&prefix)))),
534 }
535}
536
537#[cfg(test)]
538mod tests {
539 #![allow(clippy::expect_used)]
540
541 use camino::Utf8PathBuf;
542
543 use super::{Channel, Kind, Manager, Mode, Support, choose, preference, recommend, support};
544 use crate::depend::source::{ChannelEvidence, Source, TagStyle};
545 use crate::depend::target::{ManagerFile, Target};
546 use crate::depend::version::Resolved;
547 use crate::error::RkError;
548
549 fn source(channels: &[Channel]) -> Source {
550 Source {
551 path: Utf8PathBuf::from("/srv/sample"),
552 tech: Some("rust"),
553 name: Some("sample-tool".into()),
554 version: Some("1.4.0".into()),
555 bins: vec!["sam".into()],
556 owner_repo: Some("acme/sample-tool".into()),
557 host: Some("github.com".into()),
558 flake_package: channels.contains(&Channel::Flake),
559 dist_github: channels.contains(&Channel::GithubRelease),
560 binstall_github: false,
561 tag_style: TagStyle::Prefixed,
562 channels: channels
563 .iter()
564 .map(|c| ChannelEvidence {
565 channel: *c,
566 evidence: Vec::new(),
567 })
568 .collect(),
569 }
570 }
571
572 fn target(tech: Option<&'static str>, managers: &[(Manager, &str, &str)]) -> Target {
573 Target {
574 path: Utf8PathBuf::from("/srv/widget"),
575 tech,
576 managers: managers
577 .iter()
578 .map(|(manager, file, text)| ManagerFile {
579 manager: *manager,
580 file: (*file).to_owned(),
581 text: (*text).to_owned(),
582 })
583 .collect(),
584 envrc_use_flake: false,
585 already: Vec::new(),
586 }
587 }
588
589 fn resolved() -> Resolved {
590 Resolved {
591 version: "1.4.0".into(),
592 tag: "v1.4.0".into(),
593 origin: "source-tree",
594 }
595 }
596
597 #[test]
599 fn every_pair_in_the_matrix_is_classified_once() {
600 let mut fragment_pairs = 0;
601 for manager in Manager::ALL {
602 let mut seen = Vec::new();
603 for channel in preference(manager) {
604 assert!(
605 !seen.contains(&channel),
606 "{manager:?} lists {channel:?} once"
607 );
608 seen.push(channel);
609 match support(manager, channel) {
610 Support::Fragment => fragment_pairs += 1,
611 Support::Manual(reason) => {
612 assert!(
613 !reason.is_empty(),
614 "{manager:?}/{channel:?} names its reason"
615 );
616 }
617 }
618 }
619 assert_eq!(
620 seen.len(),
621 Channel::ALL.len(),
622 "{manager:?} covers every channel"
623 );
624 }
625 assert_eq!(
626 fragment_pairs, 6,
627 "flake, devbox, and four mise pairs render"
628 );
629 }
630
631 #[test]
632 fn a_cargo_dist_source_offers_crates_before_the_archive_on_mise() {
633 let options = recommend(
634 &source(&[Channel::Crates, Channel::GithubRelease]),
635 &target(None, &[(Manager::Mise, "mise.toml", "[tools]\n")]),
636 Kind::Dev,
637 &resolved(),
638 );
639 let channels: Vec<Channel> = options.iter().map(|o| o.channel).collect();
640 assert_eq!(channels, [Channel::Crates, Channel::GithubRelease]);
641 assert!(options.iter().all(|o| o.mode == Mode::Fragment));
642 assert_eq!(
643 options[0].fragments[0].text,
644 "\"cargo:sample-tool\" = \"1.4.0\""
645 );
646 assert_eq!(options[0].fragments[0].anchor.needle, Some("[tools]"));
647 assert!(options[0].seed.is_none(), "a present file is never seeded");
648 assert_eq!(
649 options[1].fragments[0].text,
650 "\"ubi:acme/sample-tool\" = { version = \"1.4.0\", exe = \"sam\" }"
651 );
652 assert_eq!(
653 options[1].freshness,
654 "mise upgrade --bump ubi:acme/sample-tool"
655 );
656 }
657
658 #[test]
659 fn a_flake_source_is_the_only_fragment_channel_for_flake_and_devbox() {
660 let source = source(&[Channel::Crates, Channel::Flake]);
661 let flake_target = target(
662 None,
663 &[(
664 Manager::Flake,
665 "flake.nix",
666 "{ inputs = {}; outputs = { self }: {}; }",
667 )],
668 );
669 let options = recommend(&source, &flake_target, Kind::Dev, &resolved());
670 assert_eq!(options[0].channel, Channel::Flake);
671 assert_eq!(options[0].mode, Mode::Fragment);
672 assert_eq!(options[0].fragments.len(), 3);
673 assert_eq!(options[0].fragments[0].present, Some(false));
674 assert_eq!(options[1].channel, Channel::Crates);
675 assert_eq!(options[1].mode, Mode::Manual);
676 assert_eq!(options[1].reason, Some("nixpkgs-attribute-unknown"));
677 let devbox = recommend(
678 &source,
679 &target(
680 None,
681 &[(Manager::Devbox, "devbox.json", "{\"packages\": []}")],
682 ),
683 Kind::Dev,
684 &resolved(),
685 );
686 assert_eq!(devbox[0].mode, Mode::Fragment);
687 assert_eq!(
688 devbox[0].fragments[0].text,
689 "\"github:acme/sample-tool/v1.4.0#default\""
690 );
691 assert_eq!(devbox[0].fragments[0].anchor.needle, Some("\"packages\""));
692 let taken = target(
693 None,
694 &[(
695 Manager::Flake,
696 "flake.nix",
697 "{ inputs = { sample-tool = { url = \"github:other/thing/v9\"; }; }; outputs = { self, sample-tool }: {}; }",
698 )],
699 );
700 let conflict = recommend(&source, &taken, Kind::Dev, &resolved());
701 assert_eq!(conflict[0].mode, Mode::Manual);
702 assert_eq!(conflict[0].reason, Some("flake-input-name-taken"));
703 let dotted = target(
704 None,
705 &[(
706 Manager::Flake,
707 "flake.nix",
708 "{ inputs.sample-tool.url = \"github:other/thing/v9\"; outputs = { self, sample-tool }: {}; }",
709 )],
710 );
711 let dotted_conflict = recommend(&source, &dotted, Kind::Dev, &resolved());
712 assert_eq!(dotted_conflict[0].reason, Some("flake-input-name-taken"));
713 let ours = target(
714 None,
715 &[(
716 Manager::Flake,
717 "flake.nix",
718 "{ inputs = { sample-tool = { url = \"github:acme/sample-tool/v1.3.0\"; }; }; outputs = { self, sample-tool }: { devShells = {}; }; }",
719 )],
720 );
721 let present = recommend(&source, &ours, Kind::Dev, &resolved());
722 assert_eq!(present[0].mode, Mode::Fragment);
723 assert_eq!(present[0].fragments[0].present, Some(true));
724 assert_eq!(present[0].fragments[1].present, Some(true));
725 let mut foreign = source;
726 foreign.host = Some("codeberg.org".into());
727 foreign.channels.retain(|c| c.channel == Channel::Flake);
728 let unknown = recommend(&foreign, &flake_target, Kind::Dev, &resolved());
729 assert_eq!(unknown[0].mode, Mode::Manual);
730 assert_eq!(unknown[0].reason, Some("forge-undetected"));
731 }
732
733 #[test]
735 fn asdf_is_always_manual_with_its_reason() {
736 let options = recommend(
737 &source(&[Channel::Crates, Channel::Flake, Channel::GithubRelease]),
738 &target(
739 None,
740 &[(Manager::Asdf, ".tool-versions", "nodejs 24.0.0\n")],
741 ),
742 Kind::Dev,
743 &resolved(),
744 );
745 assert_eq!(options.len(), 3);
746 for option in &options {
747 assert_eq!(option.mode, Mode::Manual);
748 assert_eq!(option.reason, Some("asdf-plugin-unknown"));
749 assert_eq!(option.fragments[0].text, "sample-tool 1.4.0");
750 assert!(option.seed.is_none());
751 }
752 }
753
754 #[test]
755 fn a_target_with_no_manager_lists_every_manager_as_a_seed() {
756 let options = recommend(
757 &source(&[Channel::Crates]),
758 &target(None, &[]),
759 Kind::Dev,
760 &resolved(),
761 );
762 let managers: Vec<Manager> = options.iter().filter_map(|o| o.manager).collect();
763 assert_eq!(managers, Manager::ALL);
764 assert!(options.iter().all(|o| !o.manager_present));
765 let mise = options
766 .iter()
767 .find(|o| o.manager == Some(Manager::Mise))
768 .expect("mise");
769 assert_eq!(mise.file.as_deref(), Some("mise.toml"));
770 assert_eq!(
771 mise.seed.as_ref().map(|s| s.text.as_str()),
772 Some("[tools]\n\"cargo:sample-tool\" = \"1.4.0\"\n")
773 );
774 }
775
776 #[test]
778 fn prod_returns_the_native_command_per_technology() {
779 let rust = recommend(
780 &source(&[Channel::Crates]),
781 &target(Some("rust"), &[]),
782 Kind::Prod,
783 &resolved(),
784 );
785 assert_eq!(rust[0].mode, Mode::Native);
786 assert_eq!(
787 rust[0].command.as_deref(),
788 Some("cargo add sample-tool@1.4.0")
789 );
790 assert_eq!(rust[0].freshness, "cargo update -p sample-tool");
791 let mut python = source(&[Channel::Pypi]);
792 python.tech = Some("python");
793 let py = recommend(
794 &python,
795 &target(Some("python"), &[]),
796 Kind::Prod,
797 &resolved(),
798 );
799 assert_eq!(
800 py[0].command.as_deref(),
801 Some("uv add \"sample-tool==1.4.0\"")
802 );
803 let mut node = source(&[Channel::Npm]);
804 node.tech = Some("node");
805 let js = recommend(&node, &target(Some("node"), &[]), Kind::Prod, &resolved());
806 assert_eq!(
807 js[0].command.as_deref(),
808 Some("npm install sample-tool@1.4.0")
809 );
810 }
811
812 #[test]
813 fn a_technology_mismatch_is_manual_for_prod() {
814 let options = recommend(
815 &source(&[Channel::Crates]),
816 &target(Some("python"), &[]),
817 Kind::Prod,
818 &resolved(),
819 );
820 assert_eq!(options[0].mode, Mode::Manual);
821 assert_eq!(options[0].reason, Some("technology-mismatch"));
822 assert!(options[0].command.is_none());
823 let mut nameless = source(&[]);
824 nameless.name = None;
825 assert!(
826 recommend(
827 &nameless,
828 &target(Some("rust"), &[]),
829 Kind::Prod,
830 &resolved()
831 )
832 .is_empty()
833 );
834 }
835
836 #[test]
837 fn one_present_manager_is_chosen_without_a_flag() {
838 let options = recommend(
839 &source(&[Channel::Crates, Channel::GithubRelease]),
840 &target(None, &[(Manager::Mise, "mise.toml", "")]),
841 Kind::Dev,
842 &resolved(),
843 );
844 let chosen = choose(&options, None, None).expect("chooses");
845 assert_eq!(chosen.manager, Some(Manager::Mise));
846 assert_eq!(chosen.channel, Channel::Crates);
847 let archive = choose(&options, None, Some(Channel::GithubRelease)).expect("chooses");
848 assert_eq!(archive.channel, Channel::GithubRelease);
849 assert!(matches!(
850 choose(&options, None, Some(Channel::Pypi)),
851 Err(RkError::Usage(_))
852 ));
853 assert!(matches!(
854 choose(&options, Some(Manager::Flake), None),
855 Err(RkError::Usage(_))
856 ));
857 }
858
859 #[test]
860 fn two_present_managers_need_the_flag() {
861 let options = recommend(
862 &source(&[Channel::Crates]),
863 &target(
864 None,
865 &[
866 (Manager::Flake, "flake.nix", ""),
867 (Manager::Mise, "mise.toml", ""),
868 ],
869 ),
870 Kind::Dev,
871 &resolved(),
872 );
873 let message = match choose(&options, None, None) {
874 Err(RkError::Usage(message)) => message,
875 other => format!("two managers need --manager: {other:?}"),
876 };
877 assert!(message.contains("flake and mise"), "{message}");
878 assert_eq!(
879 choose(&options, Some(Manager::Mise), None)
880 .expect("chooses")
881 .manager,
882 Some(Manager::Mise)
883 );
884 let none = recommend(
885 &source(&[Channel::Crates]),
886 &target(None, &[]),
887 Kind::Dev,
888 &resolved(),
889 );
890 assert!(matches!(choose(&none, None, None), Err(RkError::Usage(_))));
891 assert!(
892 !choose(&none, Some(Manager::Mise), None)
893 .expect("seeds")
894 .manager_present
895 );
896 }
897}