Skip to main content

release_kit/commands/
depend.rs

1//! `rk depend assess | add`: another project as a dependency of a
2//! target.
3//!
4//! `assess` reads the source and the target offline and reports every
5//! way the dependency can land, exiting 0 on every verdict; `add` serves
6//! one way — the fragments for a manager, or the technology's own
7//! command for a prod dependency — and under `--apply` seeds a manager
8//! file only where the target has none, never editing a file the target
9//! owns. Every report goes through the output boundary with a versioned
10//! schema.
11
12use serde::Serialize;
13
14use crate::cli::depend::{AddArgs, AssessArgs, DependAction, DependArgs};
15use crate::depend::fragments::Fragment;
16use crate::depend::matrix::{self, Mode, Recommendation};
17use crate::depend::source::{ChannelEvidence, Source, TagStyle};
18use crate::depend::target::{Already, Target};
19use crate::depend::version::{self, Resolved};
20use crate::depend::{self, Channel, Kind, Manager, source, target};
21use crate::devshell::Presence;
22use crate::diagnostic::{Diagnostic, Reason};
23use crate::error::RkError;
24use crate::output::Output;
25
26/// The `rk.depend-assess/1` document.
27#[derive(Debug, Serialize)]
28struct AssessReport<'a> {
29    /// The shape version of this document.
30    schema: &'static str,
31    /// `ready`, `manual-only`, `version-unknown`, or `source-unknown`.
32    verdict: &'static str,
33    /// What the source declares.
34    source: SourceView<'a>,
35    /// What the target manages.
36    target: TargetView<'a>,
37    /// The pin the options render, where the source declares a version.
38    #[serde(skip_serializing_if = "Option::is_none")]
39    resolved: Option<&'a Resolved>,
40    /// Every dev option, in report order.
41    dev: &'a [Recommendation],
42    /// The prod option, where the source is a library the target can take.
43    #[serde(skip_serializing_if = "Option::is_none")]
44    prod: Option<&'a Recommendation>,
45    /// What plausibly follows.
46    next: &'a [String],
47}
48
49/// The source half of the assessment.
50#[derive(Debug, Serialize)]
51struct SourceView<'a> {
52    /// The checkout, canonical.
53    path: &'a str,
54    /// The technology, where a manifest says.
55    #[serde(skip_serializing_if = "Option::is_none")]
56    tech: Option<&'static str>,
57    /// The package name.
58    #[serde(skip_serializing_if = "Option::is_none")]
59    name: Option<&'a str>,
60    /// The declared version.
61    #[serde(skip_serializing_if = "Option::is_none")]
62    version: Option<&'a str>,
63    /// The executables it installs.
64    bins: &'a [String],
65    /// The forge path.
66    #[serde(skip_serializing_if = "Option::is_none")]
67    owner_repo: Option<&'a str>,
68    /// The remote's host.
69    #[serde(skip_serializing_if = "Option::is_none")]
70    host: Option<&'a str>,
71    /// The shape of the release tags.
72    tag_style: TagStyle,
73    /// The viable channels and their evidence.
74    channels: &'a [ChannelEvidence],
75}
76
77/// The target half of the assessment.
78#[derive(Debug, Serialize)]
79struct TargetView<'a> {
80    /// The target, canonical.
81    path: &'a str,
82    /// The technology, where a manifest says.
83    #[serde(skip_serializing_if = "Option::is_none")]
84    tech: Option<&'static str>,
85    /// The managers present and their files.
86    managers: Vec<ManagerRow<'a>>,
87    /// Whether `.envrc` carries `use flake`.
88    envrc_use_flake: bool,
89    /// Where a manager file already names the dependency.
90    already: &'a [Already],
91}
92
93/// One present manager.
94#[derive(Debug, Serialize)]
95struct ManagerRow<'a> {
96    /// The manager.
97    manager: Manager,
98    /// Its file, relative to the target.
99    file: &'a str,
100}
101
102/// The `rk.depend-add/1` document.
103#[derive(Debug, Serialize)]
104struct AddReport<'a> {
105    /// The shape version of this document.
106    schema: &'static str,
107    /// `preview` or `apply`.
108    mode: &'static str,
109    /// `dev` or `prod`.
110    kind: Kind,
111    /// The manager, for a dev dependency.
112    #[serde(skip_serializing_if = "Option::is_none")]
113    manager: Option<Manager>,
114    /// `detected` or `argument`, for a dev dependency.
115    #[serde(skip_serializing_if = "Option::is_none")]
116    manager_origin: Option<&'static str>,
117    /// The channel.
118    channel: Channel,
119    /// `fragment`, `native`, or `manual`.
120    landing: Mode,
121    /// The target, canonical.
122    target: &'a str,
123    /// The source, canonical.
124    source: &'a str,
125    /// The package name.
126    name: &'a str,
127    /// The bare version.
128    version: &'a str,
129    /// The release tag.
130    tag: &'a str,
131    /// `argument` or `source-tree`.
132    version_origin: &'static str,
133    /// The manager file the fragments go into, for a dev dependency.
134    #[serde(skip_serializing_if = "Option::is_none")]
135    file: Option<&'a str>,
136    /// Whether that file existed before the run.
137    #[serde(skip_serializing_if = "Option::is_none")]
138    file_present: Option<Presence>,
139    /// The seed file this run wrote, relative to the target; empty in
140    /// preview.
141    written: &'a [String],
142    /// Why an owned file was refused, where one was.
143    #[serde(skip_serializing_if = "Option::is_none")]
144    refusal: Option<&'a str>,
145    /// The fragments, in application order.
146    fragments: &'a [Fragment],
147    /// The native command, for a prod dependency.
148    #[serde(skip_serializing_if = "Option::is_none")]
149    command: Option<&'a str>,
150    /// The manual reason.
151    #[serde(skip_serializing_if = "Option::is_none")]
152    reason: Option<&'static str>,
153    /// The manager's own update verb.
154    freshness: &'a str,
155    /// What plausibly follows.
156    next: &'a [String],
157}
158
159/// Dispatch one depend action.
160///
161/// # Errors
162///
163/// Returns the action's own failure.
164pub fn run(args: &DependArgs) -> Result<(), RkError> {
165    match &args.action {
166        DependAction::Assess(args) => assess(args),
167        DependAction::Add(args) => add(args),
168    }
169}
170
171/// Read both trees and lay out every option.
172fn assess(args: &AssessArgs) -> Result<(), RkError> {
173    let out = Output::new(args.json);
174    depend::reject_url(&args.source)?;
175    let source = source::observe(&args.source)?;
176    let target = target::observe(&args.target, source.name.as_deref())?;
177    let resolved = version::resolve(&source, None).ok();
178    let (dev, prod) = resolved.as_ref().map_or_else(
179        || (Vec::new(), None),
180        |resolved| {
181            (
182                matrix::recommend(&source, &target, Kind::Dev, resolved),
183                matrix::recommend(&source, &target, Kind::Prod, resolved)
184                    .into_iter()
185                    .next(),
186            )
187        },
188    );
189    let verdict = verdict(&source, resolved.as_ref(), &dev, prod.as_ref());
190    out.result_line(format!(
191        "source {}: {} {} {}",
192        source.path,
193        source.tech.unwrap_or("unknown technology"),
194        source.name.as_deref().unwrap_or("(unnamed)"),
195        source.version.as_deref().unwrap_or("(no version)")
196    ));
197    out.result_line(format!(
198        "channels: {}",
199        list(source.channels.iter().map(|c| c.channel.as_str()))
200    ));
201    out.result_line(format!(
202        "target {}: {}; managers {}",
203        target.path,
204        target.tech.unwrap_or("unknown technology"),
205        list(target.managers.iter().map(|m| m.file.as_str()))
206    ));
207    for already in &target.already {
208        out.result_line(format!(
209            "already named in {}:{}",
210            already.file, already.line
211        ));
212    }
213    for option in &dev {
214        out.result_line(option_line(option));
215    }
216    if let Some(option) = &prod {
217        out.result_line(option_line(option));
218    }
219    out.result_line(format!("verdict {verdict}"));
220    let next = assess_next(verdict, &target, &dev);
221    out.next(&next);
222    out.emit(&AssessReport {
223        schema: "rk.depend-assess/1",
224        verdict,
225        source: source_view(&source),
226        target: target_view(&target),
227        resolved: resolved.as_ref(),
228        dev: &dev,
229        prod: prod.as_ref(),
230        next: &next,
231    })
232}
233
234/// Serve one option; seed the manager file a target lacks under `--apply`.
235fn add(args: &AddArgs) -> Result<(), RkError> {
236    let out = Output::new(args.json);
237    depend::reject_url(&args.source)?;
238    let source = source::observe(&args.source)?;
239    let target = target::observe(&args.target, source.name.as_deref())?;
240    let resolved = version::resolve(&source, args.pin.as_deref())?;
241    let options = matrix::recommend(&source, &target, args.kind, &resolved);
242    let option = matrix::choose(&options, args.manager, args.channel)?;
243    let manager_origin = option.manager.map(|_| {
244        if args.manager.is_some() {
245            "argument"
246        } else {
247            "detected"
248        }
249    });
250    let file_present = option
251        .file
252        .as_deref()
253        .map(|file| Presence::of(&target.path.join(file)));
254    let mode = if args.apply { "apply" } else { "preview" };
255    let (written, refusal) = if args.apply {
256        seed_or_refuse(option, &target, file_present)?
257    } else {
258        (Vec::new(), None)
259    };
260    let name = source.name.as_deref().unwrap_or_default();
261    if args.apply {
262        for file in &written {
263            out.result_line(format!("wrote {file}"));
264        }
265    } else {
266        out.result_line("DRY RUN: rk depend add prints the fragment or the command; --apply seeds only a manager file the target lacks");
267    }
268    out.result_line(format!(
269        "{name} {} (tag {}, from the {})",
270        resolved.version, resolved.tag, resolved.origin
271    ));
272    render_option(out, option, file_present);
273    let next = add_next(option, &target, args.apply, &written);
274    out.next(&next);
275    out.emit(&AddReport {
276        schema: "rk.depend-add/1",
277        mode,
278        kind: option.kind,
279        manager: option.manager,
280        manager_origin,
281        channel: option.channel,
282        landing: option.mode,
283        target: target.path.as_str(),
284        source: source.path.as_str(),
285        name,
286        version: &resolved.version,
287        tag: &resolved.tag,
288        version_origin: resolved.origin,
289        file: option.file.as_deref(),
290        file_present,
291        written: &written,
292        refusal: refusal.as_deref(),
293        fragments: &option.fragments,
294        command: option.command.as_deref(),
295        reason: option.reason,
296        freshness: &option.freshness,
297        next: &next,
298    })?;
299    if args.apply && option.kind == Kind::Prod {
300        return Err(RkError::Usage(
301            "rk never edits Cargo.toml, pyproject.toml, or package.json; run the printed command instead of --apply".into(),
302        ));
303    }
304    let Some(message) = refusal else {
305        return Ok(());
306    };
307    Err(RkError::refusal(
308        Diagnostic::new(Reason::DestructiveRefusal, message)
309            .expected("a target with no file for the manager, or the fragments applied by hand")
310            .target_state("nothing was written; the owned file is byte-identical"),
311    ))
312}
313
314/// Under `--apply`: seed the absent manager file, or name the owned one
315/// as the refusal the report carries before the run fails.
316fn seed_or_refuse(
317    option: &Recommendation,
318    target: &Target,
319    file_present: Option<Presence>,
320) -> Result<(Vec<String>, Option<String>), RkError> {
321    match (option.mode, &option.seed, file_present) {
322        (Mode::Fragment, Some(seed), Some(Presence::Absent)) => {
323            crate::atomic::write(
324                target.path.join(&seed.file).as_std_path(),
325                seed.text.as_bytes(),
326            )?;
327            Ok((vec![seed.file.clone()], None))
328        }
329        (Mode::Fragment, _, _) => Ok((
330            Vec::new(),
331            Some(format!(
332                "the target already carries {}; rk depend add never edits a file the target owns",
333                option.file.as_deref().unwrap_or("its manager file")
334            )),
335        )),
336        (Mode::Manual, _, _) => Err(RkError::Usage(format!(
337            "the pair is manual ({}); apply the printed text by hand",
338            option.reason.unwrap_or("no reason")
339        ))),
340        (Mode::Native, _, _) => Ok((Vec::new(), None)),
341    }
342}
343
344/// The human lines of one option: its summary, its file, its fragments
345/// with their anchors, and its command.
346fn render_option(out: Output, option: &Recommendation, file_present: Option<Presence>) {
347    out.result_line(option_line(option));
348    if let (Some(file), Some(present)) = (option.file.as_deref(), file_present) {
349        out.result_line(match present {
350            Presence::Present => {
351                format!("{file} present: the target owns it, so the fragments are applied by hand")
352            }
353            Presence::Absent => format!("{file} absent: --apply seeds it"),
354        });
355    }
356    for fragment in &option.fragments {
357        out.result_line(format!(
358            "--- {} into {} ({} at {}){}",
359            fragment.id,
360            fragment.file,
361            fragment.placement,
362            fragment.anchor.path,
363            match fragment.present {
364                Some(true) => ": already present",
365                Some(false) => ": missing",
366                None => ": not judged",
367            }
368        ));
369        out.result_line(&fragment.text);
370    }
371    if let Some(command) = &option.command {
372        out.result_line(format!("run: {command}"));
373    }
374}
375
376/// The one-line verdict: `ready` where any option lands by fragment or
377/// command, `manual-only` where the source is understood but every pair
378/// is a hand edit, `version-unknown` where the source declares no
379/// version to pin, `source-unknown` where no channel has evidence.
380fn verdict(
381    source: &Source,
382    resolved: Option<&Resolved>,
383    dev: &[Recommendation],
384    prod: Option<&Recommendation>,
385) -> &'static str {
386    if source.channels.is_empty() {
387        return "source-unknown";
388    }
389    if resolved.is_none() {
390        return "version-unknown";
391    }
392    let lands = |option: &Recommendation| option.mode != Mode::Manual;
393    if dev.iter().any(lands) || prod.is_some_and(lands) {
394        "ready"
395    } else {
396        "manual-only"
397    }
398}
399
400fn option_line(option: &Recommendation) -> String {
401    use std::fmt::Write as _;
402    let kind = option
403        .manager
404        .map_or_else(|| "prod".to_owned(), |m| format!("dev {}", m.as_str()));
405    let mut line = format!(
406        "{kind} via {}: {}",
407        option.channel.as_str(),
408        mode_word(option.mode)
409    );
410    if let Some(reason) = option.reason {
411        let _ = write!(line, " ({reason})");
412    }
413    if let Some(command) = &option.command {
414        let _ = write!(line, " {command}");
415    }
416    if option.manager.is_some() && !option.manager_present {
417        line.push_str(" (seeds the file)");
418    }
419    line
420}
421
422const fn mode_word(mode: Mode) -> &'static str {
423    match mode {
424        Mode::Fragment => "fragment",
425        Mode::Native => "native",
426        Mode::Manual => "manual",
427    }
428}
429
430fn list<'a>(items: impl Iterator<Item = &'a str>) -> String {
431    let joined: Vec<&str> = items.collect();
432    if joined.is_empty() {
433        "none".to_owned()
434    } else {
435        joined.join(", ")
436    }
437}
438
439fn source_view(source: &Source) -> SourceView<'_> {
440    SourceView {
441        path: source.path.as_str(),
442        tech: source.tech,
443        name: source.name.as_deref(),
444        version: source.version.as_deref(),
445        bins: &source.bins,
446        owner_repo: source.owner_repo.as_deref(),
447        host: source.host.as_deref(),
448        tag_style: source.tag_style,
449        channels: &source.channels,
450    }
451}
452
453fn target_view(target: &Target) -> TargetView<'_> {
454    TargetView {
455        path: target.path.as_str(),
456        tech: target.tech,
457        managers: target
458            .managers
459            .iter()
460            .map(|m| ManagerRow {
461                manager: m.manager,
462                file: &m.file,
463            })
464            .collect(),
465        envrc_use_flake: target.envrc_use_flake,
466        already: &target.already,
467    }
468}
469
470fn assess_next(verdict: &str, target: &Target, dev: &[Recommendation]) -> Vec<String> {
471    let mut next = Vec::new();
472    match verdict {
473        "source-unknown" => {
474            next.push("the source declares no channel this binary reads: a Cargo.toml package, a flake with packages, a pyproject project, a package.json, or dist-workspace.toml".to_owned());
475        }
476        "version-unknown" => {
477            next.push(
478                "the source declares no version; rk depend add --pin <version> names the release to pin"
479                    .to_owned(),
480            );
481        }
482        "manual-only" => {
483            next.push(
484                "every pair is a hand edit; apply the printed text with the reason in view"
485                    .to_owned(),
486            );
487        }
488        _ => {
489            if target.managers.len() > 1 {
490                next.push("rk depend add --kind dev --manager <manager> (the target carries more than one)".to_owned());
491            } else if target.managers.is_empty() && !dev.is_empty() {
492                next.push(
493                    "rk depend add --kind dev --manager <manager> seeds the file the target lacks"
494                        .to_owned(),
495                );
496            } else {
497                next.push("rk depend add --kind dev|prod previews the landing".to_owned());
498            }
499        }
500    }
501    next
502}
503
504fn add_next(
505    option: &Recommendation,
506    target: &Target,
507    applied: bool,
508    written: &[String],
509) -> Vec<String> {
510    let mut next = Vec::new();
511    match option.mode {
512        Mode::Native => {
513            if let Some(command) = &option.command {
514                next.push(format!(
515                    "run {command} in the target, then commit the manifest and its lock"
516                ));
517            }
518        }
519        Mode::Manual => {
520            next.push(format!(
521                "apply the printed text by hand: {}",
522                option.reason.unwrap_or("manual")
523            ));
524        }
525        Mode::Fragment => {
526            if !applied && option.manager_present {
527                next.push("apply each fragment at its anchor in the order printed".to_owned());
528            } else if !applied {
529                next.push("rk depend add --apply seeds the manager file".to_owned());
530            }
531            if written.iter().any(|f| f == "flake.nix") && !target.envrc_use_flake {
532                next.push(
533                    "let direnv load the flake from .envrc, or enter the shell with nix develop"
534                        .to_owned(),
535                );
536            }
537            if let Some(manager) = option.manager {
538                next.push(match manager {
539                    Manager::Flake => {
540                        "nix flake lock, then commit flake.nix and flake.lock".to_owned()
541                    }
542                    Manager::Mise => "mise install, then commit the mise configuration".to_owned(),
543                    Manager::Asdf => "asdf install, then commit .tool-versions".to_owned(),
544                    Manager::Devbox => {
545                        "devbox install, then commit devbox.json and devbox.lock".to_owned()
546                    }
547                });
548            }
549        }
550    }
551    if !option.freshness.is_empty() {
552        next.push(format!(
553            "freshness is the manager's own verb: {}",
554            option.freshness
555        ));
556    }
557    next
558}
559
560#[cfg(test)]
561mod tests {
562    #![allow(clippy::expect_used)]
563
564    use camino::Utf8PathBuf;
565
566    use super::{AddReport, AssessReport, ManagerRow, SourceView, TargetView};
567    use crate::depend::fragments::{Anchor, Fragment};
568    use crate::depend::matrix::{Mode, Recommendation, Seed};
569    use crate::depend::source::{ChannelEvidence, TagStyle};
570    use crate::depend::target::Already;
571    use crate::depend::version::Resolved;
572    use crate::depend::{Channel, Kind, Manager};
573    use crate::devshell::Presence;
574
575    fn fragment() -> Fragment {
576        Fragment {
577            id: "mise-tool",
578            file: "mise.toml".to_owned(),
579            role: "the pinned tool entry",
580            placement: "insert-into-table",
581            anchor: Anchor {
582                kind: "table",
583                path: "tools".to_owned(),
584                needle: Some("[tools]"),
585            },
586            text: "\"cargo:sample-tool\" = \"1.4.0\"".to_owned(),
587            present: Some(false),
588        }
589    }
590
591    /// The complete `rk.depend-assess/1` shape, held by snapshot.
592    #[test]
593    #[allow(clippy::too_many_lines)]
594    fn the_depend_assess_schema_snapshot_holds() {
595        let channels = vec![ChannelEvidence {
596            channel: Channel::Crates,
597            evidence: vec!["Cargo.toml names a package".to_owned()],
598        }];
599        let bins = vec!["sam".to_owned()];
600        let already = vec![Already {
601            manager: Manager::Mise,
602            file: "mise.toml".to_owned(),
603            line: 3,
604        }];
605        let resolved = Resolved {
606            version: "1.4.0".to_owned(),
607            tag: "v1.4.0".to_owned(),
608            origin: "source-tree",
609        };
610        let dev = vec![Recommendation {
611            kind: Kind::Dev,
612            manager: Some(Manager::Mise),
613            manager_present: true,
614            channel: Channel::Crates,
615            mode: Mode::Fragment,
616            file: Some("mise.toml".to_owned()),
617            fragments: vec![fragment()],
618            seed: None,
619            command: None,
620            reason: None,
621            freshness: "mise upgrade --bump cargo:sample-tool".to_owned(),
622        }];
623        let prod = Recommendation {
624            kind: Kind::Prod,
625            manager: None,
626            manager_present: false,
627            channel: Channel::Crates,
628            mode: Mode::Native,
629            file: None,
630            fragments: Vec::new(),
631            seed: None,
632            command: Some("cargo add sample-tool@1.4.0".to_owned()),
633            reason: None,
634            freshness: "cargo update -p sample-tool".to_owned(),
635        };
636        let next = vec!["rk depend add --kind dev|prod previews the landing".to_owned()];
637        let report = AssessReport {
638            schema: "rk.depend-assess/1",
639            verdict: "ready",
640            source: SourceView {
641                path: "/srv/sample",
642                tech: Some("rust"),
643                name: Some("sample-tool"),
644                version: Some("1.4.0"),
645                bins: &bins,
646                owner_repo: Some("acme/sample-tool"),
647                host: Some("github.com"),
648                tag_style: TagStyle::Prefixed,
649                channels: &channels,
650            },
651            target: TargetView {
652                path: "/srv/widget",
653                tech: Some("rust"),
654                managers: vec![ManagerRow {
655                    manager: Manager::Mise,
656                    file: "mise.toml",
657                }],
658                envrc_use_flake: false,
659                already: &already,
660            },
661            resolved: Some(&resolved),
662            dev: &dev,
663            prod: Some(&prod),
664            next: &next,
665        };
666        assert_eq!(
667            serde_json::to_string(&report).expect("a report serializes"),
668            r#"{"schema":"rk.depend-assess/1","verdict":"ready","source":{"path":"/srv/sample","tech":"rust","name":"sample-tool","version":"1.4.0","bins":["sam"],"owner_repo":"acme/sample-tool","host":"github.com","tag_style":"prefixed","channels":[{"channel":"crates","evidence":["Cargo.toml names a package"]}]},"target":{"path":"/srv/widget","tech":"rust","managers":[{"manager":"mise","file":"mise.toml"}],"envrc_use_flake":false,"already":[{"manager":"mise","file":"mise.toml","line":3}]},"resolved":{"version":"1.4.0","tag":"v1.4.0","origin":"source-tree"},"dev":[{"kind":"dev","manager":"mise","manager_present":true,"channel":"crates","mode":"fragment","file":"mise.toml","fragments":[{"id":"mise-tool","file":"mise.toml","role":"the pinned tool entry","placement":"insert-into-table","anchor":{"kind":"table","path":"tools","needle":"[tools]"},"text":"\"cargo:sample-tool\" = \"1.4.0\"","present":false}],"freshness":"mise upgrade --bump cargo:sample-tool"}],"prod":{"kind":"prod","manager_present":false,"channel":"crates","mode":"native","fragments":[],"command":"cargo add sample-tool@1.4.0","freshness":"cargo update -p sample-tool"},"next":["rk depend add --kind dev|prod previews the landing"]}"#
669        );
670        let bare = AssessReport {
671            schema: "rk.depend-assess/1",
672            verdict: "source-unknown",
673            source: SourceView {
674                path: "/srv/sample",
675                tech: None,
676                name: None,
677                version: None,
678                bins: &[],
679                owner_repo: None,
680                host: None,
681                tag_style: TagStyle::Unknown,
682                channels: &[],
683            },
684            target: TargetView {
685                path: "/srv/widget",
686                tech: None,
687                managers: Vec::new(),
688                envrc_use_flake: false,
689                already: &[],
690            },
691            resolved: None,
692            dev: &[],
693            prod: None,
694            next: &[],
695        };
696        assert_eq!(
697            serde_json::to_string(&bare).expect("a report serializes"),
698            r#"{"schema":"rk.depend-assess/1","verdict":"source-unknown","source":{"path":"/srv/sample","bins":[],"tag_style":"unknown","channels":[]},"target":{"path":"/srv/widget","managers":[],"envrc_use_flake":false,"already":[]},"dev":[],"next":[]}"#,
699            "an unknown value is omitted, never null"
700        );
701    }
702
703    /// The complete `rk.depend-add/1` shape, held by snapshot.
704    #[test]
705    fn the_depend_add_schema_snapshot_holds() {
706        let fragments = vec![fragment()];
707        let written = vec!["mise.toml".to_owned()];
708        let next = vec!["mise install, then commit the mise configuration".to_owned()];
709        let report = AddReport {
710            schema: "rk.depend-add/1",
711            mode: "apply",
712            kind: Kind::Dev,
713            manager: Some(Manager::Mise),
714            manager_origin: Some("detected"),
715            channel: Channel::Crates,
716            landing: Mode::Fragment,
717            target: "/srv/widget",
718            source: "/srv/sample",
719            name: "sample-tool",
720            version: "1.4.0",
721            tag: "v1.4.0",
722            version_origin: "source-tree",
723            file: Some("mise.toml"),
724            file_present: Some(Presence::Absent),
725            written: &written,
726            refusal: None,
727            fragments: &fragments,
728            command: None,
729            reason: None,
730            freshness: "mise upgrade --bump cargo:sample-tool",
731            next: &next,
732        };
733        assert_eq!(
734            serde_json::to_string(&report).expect("a report serializes"),
735            r#"{"schema":"rk.depend-add/1","mode":"apply","kind":"dev","manager":"mise","manager_origin":"detected","channel":"crates","landing":"fragment","target":"/srv/widget","source":"/srv/sample","name":"sample-tool","version":"1.4.0","tag":"v1.4.0","version_origin":"source-tree","file":"mise.toml","file_present":"absent","written":["mise.toml"],"fragments":[{"id":"mise-tool","file":"mise.toml","role":"the pinned tool entry","placement":"insert-into-table","anchor":{"kind":"table","path":"tools","needle":"[tools]"},"text":"\"cargo:sample-tool\" = \"1.4.0\"","present":false}],"freshness":"mise upgrade --bump cargo:sample-tool","next":["mise install, then commit the mise configuration"]}"#
736        );
737        let bare = AddReport {
738            schema: "rk.depend-add/1",
739            mode: "preview",
740            kind: Kind::Prod,
741            manager: None,
742            manager_origin: None,
743            channel: Channel::Crates,
744            landing: Mode::Native,
745            target: "/srv/widget",
746            source: "/srv/sample",
747            name: "sample-tool",
748            version: "1.4.0",
749            tag: "v1.4.0",
750            version_origin: "argument",
751            file: None,
752            file_present: None,
753            written: &[],
754            refusal: None,
755            fragments: &[],
756            command: Some("cargo add sample-tool@1.4.0"),
757            reason: None,
758            freshness: "cargo update -p sample-tool",
759            next: &[],
760        };
761        assert_eq!(
762            serde_json::to_string(&bare).expect("a report serializes"),
763            r#"{"schema":"rk.depend-add/1","mode":"preview","kind":"prod","channel":"crates","landing":"native","target":"/srv/widget","source":"/srv/sample","name":"sample-tool","version":"1.4.0","tag":"v1.4.0","version_origin":"argument","written":[],"fragments":[],"command":"cargo add sample-tool@1.4.0","freshness":"cargo update -p sample-tool","next":[]}"#,
764            "an unknown value is omitted, never null"
765        );
766        let seed = Seed {
767            file: "mise.toml".to_owned(),
768            text: "[tools]\n".to_owned(),
769        };
770        assert_eq!(
771            serde_json::to_string(&seed).expect("a seed serializes"),
772            r#"{"file":"mise.toml","text":"[tools]\n"}"#
773        );
774        let _ = Utf8PathBuf::from("/srv/widget");
775    }
776}