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    use camino::Utf8PathBuf;
563
564    use super::{AddReport, AssessReport, ManagerRow, SourceView, TargetView};
565    use crate::depend::fragments::{Anchor, Fragment};
566    use crate::depend::matrix::{Mode, Recommendation, Seed};
567    use crate::depend::source::{ChannelEvidence, TagStyle};
568    use crate::depend::target::Already;
569    use crate::depend::version::Resolved;
570    use crate::depend::{Channel, Kind, Manager};
571    use crate::devshell::Presence;
572
573    fn fragment() -> Fragment {
574        Fragment {
575            id: "mise-tool",
576            file: "mise.toml".to_owned(),
577            role: "the pinned tool entry",
578            placement: "insert-into-table",
579            anchor: Anchor {
580                kind: "table",
581                path: "tools".to_owned(),
582                needle: Some("[tools]"),
583            },
584            text: "\"cargo:sample-tool\" = \"1.4.0\"".to_owned(),
585            present: Some(false),
586        }
587    }
588
589    /// The complete `rk.depend-assess/1` shape, held by snapshot.
590    #[test]
591    #[allow(
592        clippy::too_many_lines,
593        reason = "the snapshot is one literal shape, and splitting it would hide what the schema holds"
594    )]
595    fn the_depend_assess_schema_snapshot_holds() {
596        let channels = vec![ChannelEvidence {
597            channel: Channel::Crates,
598            evidence: vec!["Cargo.toml names a package".to_owned()],
599        }];
600        let bins = vec!["sam".to_owned()];
601        let already = vec![Already {
602            manager: Manager::Mise,
603            file: "mise.toml".to_owned(),
604            line: 3,
605        }];
606        let resolved = Resolved {
607            version: "1.4.0".to_owned(),
608            tag: "v1.4.0".to_owned(),
609            origin: "source-tree",
610        };
611        let dev = vec![Recommendation {
612            kind: Kind::Dev,
613            manager: Some(Manager::Mise),
614            manager_present: true,
615            channel: Channel::Crates,
616            mode: Mode::Fragment,
617            file: Some("mise.toml".to_owned()),
618            fragments: vec![fragment()],
619            seed: None,
620            command: None,
621            reason: None,
622            freshness: "mise upgrade --bump cargo:sample-tool".to_owned(),
623        }];
624        let prod = Recommendation {
625            kind: Kind::Prod,
626            manager: None,
627            manager_present: false,
628            channel: Channel::Crates,
629            mode: Mode::Native,
630            file: None,
631            fragments: Vec::new(),
632            seed: None,
633            command: Some("cargo add sample-tool@1.4.0".to_owned()),
634            reason: None,
635            freshness: "cargo update -p sample-tool".to_owned(),
636        };
637        let next = vec!["rk depend add --kind dev|prod previews the landing".to_owned()];
638        let report = AssessReport {
639            schema: "rk.depend-assess/1",
640            verdict: "ready",
641            source: SourceView {
642                path: "/srv/sample",
643                tech: Some("rust"),
644                name: Some("sample-tool"),
645                version: Some("1.4.0"),
646                bins: &bins,
647                owner_repo: Some("acme/sample-tool"),
648                host: Some("github.com"),
649                tag_style: TagStyle::Prefixed,
650                channels: &channels,
651            },
652            target: TargetView {
653                path: "/srv/widget",
654                tech: Some("rust"),
655                managers: vec![ManagerRow {
656                    manager: Manager::Mise,
657                    file: "mise.toml",
658                }],
659                envrc_use_flake: false,
660                already: &already,
661            },
662            resolved: Some(&resolved),
663            dev: &dev,
664            prod: Some(&prod),
665            next: &next,
666        };
667        assert_eq!(
668            serde_json::to_string(&report).expect("a report serializes"),
669            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"]}"#
670        );
671        let bare = AssessReport {
672            schema: "rk.depend-assess/1",
673            verdict: "source-unknown",
674            source: SourceView {
675                path: "/srv/sample",
676                tech: None,
677                name: None,
678                version: None,
679                bins: &[],
680                owner_repo: None,
681                host: None,
682                tag_style: TagStyle::Unknown,
683                channels: &[],
684            },
685            target: TargetView {
686                path: "/srv/widget",
687                tech: None,
688                managers: Vec::new(),
689                envrc_use_flake: false,
690                already: &[],
691            },
692            resolved: None,
693            dev: &[],
694            prod: None,
695            next: &[],
696        };
697        assert_eq!(
698            serde_json::to_string(&bare).expect("a report serializes"),
699            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":[]}"#,
700            "an unknown value is omitted, never null"
701        );
702    }
703
704    /// The complete `rk.depend-add/1` shape, held by snapshot.
705    #[test]
706    fn the_depend_add_schema_snapshot_holds() {
707        let fragments = vec![fragment()];
708        let written = vec!["mise.toml".to_owned()];
709        let next = vec!["mise install, then commit the mise configuration".to_owned()];
710        let report = AddReport {
711            schema: "rk.depend-add/1",
712            mode: "apply",
713            kind: Kind::Dev,
714            manager: Some(Manager::Mise),
715            manager_origin: Some("detected"),
716            channel: Channel::Crates,
717            landing: Mode::Fragment,
718            target: "/srv/widget",
719            source: "/srv/sample",
720            name: "sample-tool",
721            version: "1.4.0",
722            tag: "v1.4.0",
723            version_origin: "source-tree",
724            file: Some("mise.toml"),
725            file_present: Some(Presence::Absent),
726            written: &written,
727            refusal: None,
728            fragments: &fragments,
729            command: None,
730            reason: None,
731            freshness: "mise upgrade --bump cargo:sample-tool",
732            next: &next,
733        };
734        assert_eq!(
735            serde_json::to_string(&report).expect("a report serializes"),
736            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"]}"#
737        );
738        let bare = AddReport {
739            schema: "rk.depend-add/1",
740            mode: "preview",
741            kind: Kind::Prod,
742            manager: None,
743            manager_origin: None,
744            channel: Channel::Crates,
745            landing: Mode::Native,
746            target: "/srv/widget",
747            source: "/srv/sample",
748            name: "sample-tool",
749            version: "1.4.0",
750            tag: "v1.4.0",
751            version_origin: "argument",
752            file: None,
753            file_present: None,
754            written: &[],
755            refusal: None,
756            fragments: &[],
757            command: Some("cargo add sample-tool@1.4.0"),
758            reason: None,
759            freshness: "cargo update -p sample-tool",
760            next: &[],
761        };
762        assert_eq!(
763            serde_json::to_string(&bare).expect("a report serializes"),
764            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":[]}"#,
765            "an unknown value is omitted, never null"
766        );
767        let seed = Seed {
768            file: "mise.toml".to_owned(),
769            text: "[tools]\n".to_owned(),
770        };
771        assert_eq!(
772            serde_json::to_string(&seed).expect("a seed serializes"),
773            r#"{"file":"mise.toml","text":"[tools]\n"}"#
774        );
775        let _ = Utf8PathBuf::from("/srv/widget");
776    }
777}