Skip to main content

release_kit/depend/
matrix.rs

1//! The manager × channel matrix: which pairs land as a fragment, which
2//! as the technology's own command, and which are a hand edit with a
3//! named reason.
4//!
5//! The matrix guesses nothing it cannot read offline: a nixpkgs
6//! attribute, an asdf plugin name, a source hash are unknown here, so
7//! the pairs that need one are `manual` with that reason, and the
8//! operator or the agent finishes them from the report.
9
10use 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/// How a pair lands.
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
21#[serde(rename_all = "kebab-case")]
22pub enum Mode {
23    /// Text to place in the manager's file, seeded where the file is absent.
24    Fragment,
25    /// The technology's own command, run by the operator.
26    Native,
27    /// A hand edit the report describes; nothing is written.
28    Manual,
29}
30
31/// Whether a pair is supported, and why not where it is not.
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum Support {
34    /// The pair renders a fragment.
35    Fragment,
36    /// The pair needs knowledge the binary does not have offline.
37    Manual(&'static str),
38}
39
40/// A seed file for a manager the target has no file for.
41#[derive(Debug, Clone, Serialize)]
42pub struct Seed {
43    /// The file, relative to the target.
44    pub file: String,
45    /// Its whole text.
46    pub text: String,
47}
48
49/// One way the dependency can land.
50#[derive(Debug, Clone, Serialize)]
51pub struct Recommendation {
52    /// `dev` or `prod`.
53    pub kind: Kind,
54    /// The manager, for a dev dependency.
55    #[serde(skip_serializing_if = "Option::is_none")]
56    pub manager: Option<Manager>,
57    /// Whether the target already carries the manager's file.
58    pub manager_present: bool,
59    /// The channel.
60    pub channel: Channel,
61    /// `fragment`, `native`, or `manual`.
62    pub mode: Mode,
63    /// The manager file the fragments go into, for a dev dependency.
64    #[serde(skip_serializing_if = "Option::is_none")]
65    pub file: Option<String>,
66    /// The fragments, in application order.
67    pub fragments: Vec<Fragment>,
68    /// The seed, where the manager file is absent and the pair renders one.
69    #[serde(skip_serializing_if = "Option::is_none")]
70    pub seed: Option<Seed>,
71    /// The native command, for a prod dependency.
72    #[serde(skip_serializing_if = "Option::is_none")]
73    pub command: Option<String>,
74    /// The manual reason.
75    #[serde(skip_serializing_if = "Option::is_none")]
76    pub reason: Option<&'static str>,
77    /// The manager's own update verb.
78    pub freshness: String,
79}
80
81/// The support of one pair.
82#[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/// The channels a manager prefers, first first.
98#[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/// Every way the dependency can land as the given kind, in report order.
119#[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
132/// Pick the one option `add` serves, from the flags and the target.
133///
134/// # Errors
135///
136/// Returns [`RkError::Usage`] where the manager is ambiguous or the pair
137/// is not viable, naming the choices.
138pub 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
196/// The one manager the target carries, or the usage error naming why
197/// `--manager` is needed.
198fn 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
222/// The tokens every block renders from.
223fn 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
373/// Whether the target's flake already binds the input name to another
374/// source: the alias is derived from the package name, so two names can
375/// share it, and a binding whose URL is not this repository's is a
376/// conflict the report names rather than a presence.
377fn 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    /// SATISFIES dependencies:an-unjudgeable-pair-is-manual-with-its-reason
596    #[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    /// SATISFIES dependencies:an-unjudgeable-pair-is-manual-with-its-reason
732    #[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    /// SATISFIES dependencies:a-prod-dependency-lands-through-the-native-command
775    #[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}