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 use camino::Utf8PathBuf;
540
541 use super::{Channel, Kind, Manager, Mode, Support, choose, preference, recommend, support};
542 use crate::depend::source::{ChannelEvidence, Source, TagStyle};
543 use crate::depend::target::{ManagerFile, Target};
544 use crate::depend::version::Resolved;
545 use crate::error::RkError;
546
547 fn source(channels: &[Channel]) -> Source {
548 Source {
549 path: Utf8PathBuf::from("/srv/sample"),
550 tech: Some("rust"),
551 name: Some("sample-tool".into()),
552 version: Some("1.4.0".into()),
553 bins: vec!["sam".into()],
554 owner_repo: Some("acme/sample-tool".into()),
555 host: Some("github.com".into()),
556 flake_package: channels.contains(&Channel::Flake),
557 dist_github: channels.contains(&Channel::GithubRelease),
558 binstall_github: false,
559 tag_style: TagStyle::Prefixed,
560 channels: channels
561 .iter()
562 .map(|c| ChannelEvidence {
563 channel: *c,
564 evidence: Vec::new(),
565 })
566 .collect(),
567 }
568 }
569
570 fn target(tech: Option<&'static str>, managers: &[(Manager, &str, &str)]) -> Target {
571 Target {
572 path: Utf8PathBuf::from("/srv/widget"),
573 tech,
574 managers: managers
575 .iter()
576 .map(|(manager, file, text)| ManagerFile {
577 manager: *manager,
578 file: (*file).to_owned(),
579 text: (*text).to_owned(),
580 })
581 .collect(),
582 envrc_use_flake: false,
583 already: Vec::new(),
584 }
585 }
586
587 fn resolved() -> Resolved {
588 Resolved {
589 version: "1.4.0".into(),
590 tag: "v1.4.0".into(),
591 origin: "source-tree",
592 }
593 }
594
595 #[test]
597 fn every_pair_in_the_matrix_is_classified_once() {
598 let mut fragment_pairs = 0;
599 for manager in Manager::ALL {
600 let mut seen = Vec::new();
601 for channel in preference(manager) {
602 assert!(
603 !seen.contains(&channel),
604 "{manager:?} lists {channel:?} once"
605 );
606 seen.push(channel);
607 match support(manager, channel) {
608 Support::Fragment => fragment_pairs += 1,
609 Support::Manual(reason) => {
610 assert!(
611 !reason.is_empty(),
612 "{manager:?}/{channel:?} names its reason"
613 );
614 }
615 }
616 }
617 assert_eq!(
618 seen.len(),
619 Channel::ALL.len(),
620 "{manager:?} covers every channel"
621 );
622 }
623 assert_eq!(
624 fragment_pairs, 6,
625 "flake, devbox, and four mise pairs render"
626 );
627 }
628
629 #[test]
630 fn a_cargo_dist_source_offers_crates_before_the_archive_on_mise() {
631 let options = recommend(
632 &source(&[Channel::Crates, Channel::GithubRelease]),
633 &target(None, &[(Manager::Mise, "mise.toml", "[tools]\n")]),
634 Kind::Dev,
635 &resolved(),
636 );
637 let channels: Vec<Channel> = options.iter().map(|o| o.channel).collect();
638 assert_eq!(channels, [Channel::Crates, Channel::GithubRelease]);
639 assert!(options.iter().all(|o| o.mode == Mode::Fragment));
640 assert_eq!(
641 options[0].fragments[0].text,
642 "\"cargo:sample-tool\" = \"1.4.0\""
643 );
644 assert_eq!(options[0].fragments[0].anchor.needle, Some("[tools]"));
645 assert!(options[0].seed.is_none(), "a present file is never seeded");
646 assert_eq!(
647 options[1].fragments[0].text,
648 "\"ubi:acme/sample-tool\" = { version = \"1.4.0\", exe = \"sam\" }"
649 );
650 assert_eq!(
651 options[1].freshness,
652 "mise upgrade --bump ubi:acme/sample-tool"
653 );
654 }
655
656 #[test]
657 fn a_flake_source_is_the_only_fragment_channel_for_flake_and_devbox() {
658 let source = source(&[Channel::Crates, Channel::Flake]);
659 let flake_target = target(
660 None,
661 &[(
662 Manager::Flake,
663 "flake.nix",
664 "{ inputs = {}; outputs = { self }: {}; }",
665 )],
666 );
667 let options = recommend(&source, &flake_target, Kind::Dev, &resolved());
668 assert_eq!(options[0].channel, Channel::Flake);
669 assert_eq!(options[0].mode, Mode::Fragment);
670 assert_eq!(options[0].fragments.len(), 3);
671 assert_eq!(options[0].fragments[0].present, Some(false));
672 assert_eq!(options[1].channel, Channel::Crates);
673 assert_eq!(options[1].mode, Mode::Manual);
674 assert_eq!(options[1].reason, Some("nixpkgs-attribute-unknown"));
675 let devbox = recommend(
676 &source,
677 &target(
678 None,
679 &[(Manager::Devbox, "devbox.json", "{\"packages\": []}")],
680 ),
681 Kind::Dev,
682 &resolved(),
683 );
684 assert_eq!(devbox[0].mode, Mode::Fragment);
685 assert_eq!(
686 devbox[0].fragments[0].text,
687 "\"github:acme/sample-tool/v1.4.0#default\""
688 );
689 assert_eq!(devbox[0].fragments[0].anchor.needle, Some("\"packages\""));
690 let taken = target(
691 None,
692 &[(
693 Manager::Flake,
694 "flake.nix",
695 "{ inputs = { sample-tool = { url = \"github:other/thing/v9\"; }; }; outputs = { self, sample-tool }: {}; }",
696 )],
697 );
698 let conflict = recommend(&source, &taken, Kind::Dev, &resolved());
699 assert_eq!(conflict[0].mode, Mode::Manual);
700 assert_eq!(conflict[0].reason, Some("flake-input-name-taken"));
701 let dotted = target(
702 None,
703 &[(
704 Manager::Flake,
705 "flake.nix",
706 "{ inputs.sample-tool.url = \"github:other/thing/v9\"; outputs = { self, sample-tool }: {}; }",
707 )],
708 );
709 let dotted_conflict = recommend(&source, &dotted, Kind::Dev, &resolved());
710 assert_eq!(dotted_conflict[0].reason, Some("flake-input-name-taken"));
711 let ours = target(
712 None,
713 &[(
714 Manager::Flake,
715 "flake.nix",
716 "{ inputs = { sample-tool = { url = \"github:acme/sample-tool/v1.3.0\"; }; }; outputs = { self, sample-tool }: { devShells = {}; }; }",
717 )],
718 );
719 let present = recommend(&source, &ours, Kind::Dev, &resolved());
720 assert_eq!(present[0].mode, Mode::Fragment);
721 assert_eq!(present[0].fragments[0].present, Some(true));
722 assert_eq!(present[0].fragments[1].present, Some(true));
723 let mut foreign = source;
724 foreign.host = Some("codeberg.org".into());
725 foreign.channels.retain(|c| c.channel == Channel::Flake);
726 let unknown = recommend(&foreign, &flake_target, Kind::Dev, &resolved());
727 assert_eq!(unknown[0].mode, Mode::Manual);
728 assert_eq!(unknown[0].reason, Some("forge-undetected"));
729 }
730
731 #[test]
733 fn asdf_is_always_manual_with_its_reason() {
734 let options = recommend(
735 &source(&[Channel::Crates, Channel::Flake, Channel::GithubRelease]),
736 &target(
737 None,
738 &[(Manager::Asdf, ".tool-versions", "nodejs 24.0.0\n")],
739 ),
740 Kind::Dev,
741 &resolved(),
742 );
743 assert_eq!(options.len(), 3);
744 for option in &options {
745 assert_eq!(option.mode, Mode::Manual);
746 assert_eq!(option.reason, Some("asdf-plugin-unknown"));
747 assert_eq!(option.fragments[0].text, "sample-tool 1.4.0");
748 assert!(option.seed.is_none());
749 }
750 }
751
752 #[test]
753 fn a_target_with_no_manager_lists_every_manager_as_a_seed() {
754 let options = recommend(
755 &source(&[Channel::Crates]),
756 &target(None, &[]),
757 Kind::Dev,
758 &resolved(),
759 );
760 let managers: Vec<Manager> = options.iter().filter_map(|o| o.manager).collect();
761 assert_eq!(managers, Manager::ALL);
762 assert!(options.iter().all(|o| !o.manager_present));
763 let mise = options
764 .iter()
765 .find(|o| o.manager == Some(Manager::Mise))
766 .expect("mise");
767 assert_eq!(mise.file.as_deref(), Some("mise.toml"));
768 assert_eq!(
769 mise.seed.as_ref().map(|s| s.text.as_str()),
770 Some("[tools]\n\"cargo:sample-tool\" = \"1.4.0\"\n")
771 );
772 }
773
774 #[test]
776 fn prod_returns_the_native_command_per_technology() {
777 let rust = recommend(
778 &source(&[Channel::Crates]),
779 &target(Some("rust"), &[]),
780 Kind::Prod,
781 &resolved(),
782 );
783 assert_eq!(rust[0].mode, Mode::Native);
784 assert_eq!(
785 rust[0].command.as_deref(),
786 Some("cargo add sample-tool@1.4.0")
787 );
788 assert_eq!(rust[0].freshness, "cargo update -p sample-tool");
789 let mut python = source(&[Channel::Pypi]);
790 python.tech = Some("python");
791 let py = recommend(
792 &python,
793 &target(Some("python"), &[]),
794 Kind::Prod,
795 &resolved(),
796 );
797 assert_eq!(
798 py[0].command.as_deref(),
799 Some("uv add \"sample-tool==1.4.0\"")
800 );
801 let mut node = source(&[Channel::Npm]);
802 node.tech = Some("node");
803 let js = recommend(&node, &target(Some("node"), &[]), Kind::Prod, &resolved());
804 assert_eq!(
805 js[0].command.as_deref(),
806 Some("npm install sample-tool@1.4.0")
807 );
808 }
809
810 #[test]
811 fn a_technology_mismatch_is_manual_for_prod() {
812 let options = recommend(
813 &source(&[Channel::Crates]),
814 &target(Some("python"), &[]),
815 Kind::Prod,
816 &resolved(),
817 );
818 assert_eq!(options[0].mode, Mode::Manual);
819 assert_eq!(options[0].reason, Some("technology-mismatch"));
820 assert!(options[0].command.is_none());
821 let mut nameless = source(&[]);
822 nameless.name = None;
823 assert!(
824 recommend(
825 &nameless,
826 &target(Some("rust"), &[]),
827 Kind::Prod,
828 &resolved()
829 )
830 .is_empty()
831 );
832 }
833
834 #[test]
835 fn one_present_manager_is_chosen_without_a_flag() {
836 let options = recommend(
837 &source(&[Channel::Crates, Channel::GithubRelease]),
838 &target(None, &[(Manager::Mise, "mise.toml", "")]),
839 Kind::Dev,
840 &resolved(),
841 );
842 let chosen = choose(&options, None, None).expect("chooses");
843 assert_eq!(chosen.manager, Some(Manager::Mise));
844 assert_eq!(chosen.channel, Channel::Crates);
845 let archive = choose(&options, None, Some(Channel::GithubRelease)).expect("chooses");
846 assert_eq!(archive.channel, Channel::GithubRelease);
847 assert!(matches!(
848 choose(&options, None, Some(Channel::Pypi)),
849 Err(RkError::Usage(_))
850 ));
851 assert!(matches!(
852 choose(&options, Some(Manager::Flake), None),
853 Err(RkError::Usage(_))
854 ));
855 }
856
857 #[test]
858 fn two_present_managers_need_the_flag() {
859 let options = recommend(
860 &source(&[Channel::Crates]),
861 &target(
862 None,
863 &[
864 (Manager::Flake, "flake.nix", ""),
865 (Manager::Mise, "mise.toml", ""),
866 ],
867 ),
868 Kind::Dev,
869 &resolved(),
870 );
871 let message = match choose(&options, None, None) {
872 Err(RkError::Usage(message)) => message,
873 other => format!("two managers need --manager: {other:?}"),
874 };
875 assert!(message.contains("flake and mise"), "{message}");
876 assert_eq!(
877 choose(&options, Some(Manager::Mise), None)
878 .expect("chooses")
879 .manager,
880 Some(Manager::Mise)
881 );
882 let none = recommend(
883 &source(&[Channel::Crates]),
884 &target(None, &[]),
885 Kind::Dev,
886 &resolved(),
887 );
888 assert!(matches!(choose(&none, None, None), Err(RkError::Usage(_))));
889 assert!(
890 !choose(&none, Some(Manager::Mise), None)
891 .expect("seeds")
892 .manager_present
893 );
894 }
895}