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    #![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    /// SATISFIES dependencies:an-unjudgeable-pair-is-manual-with-its-reason
598    #[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    /// SATISFIES dependencies:an-unjudgeable-pair-is-manual-with-its-reason
734    #[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    /// SATISFIES dependencies:a-prod-dependency-lands-through-the-native-command
777    #[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}