1use 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#[derive(Debug, Serialize)]
28struct AssessReport<'a> {
29 schema: &'static str,
31 verdict: &'static str,
33 source: SourceView<'a>,
35 target: TargetView<'a>,
37 #[serde(skip_serializing_if = "Option::is_none")]
39 resolved: Option<&'a Resolved>,
40 dev: &'a [Recommendation],
42 #[serde(skip_serializing_if = "Option::is_none")]
44 prod: Option<&'a Recommendation>,
45 next: &'a [String],
47}
48
49#[derive(Debug, Serialize)]
51struct SourceView<'a> {
52 path: &'a str,
54 #[serde(skip_serializing_if = "Option::is_none")]
56 tech: Option<&'static str>,
57 #[serde(skip_serializing_if = "Option::is_none")]
59 name: Option<&'a str>,
60 #[serde(skip_serializing_if = "Option::is_none")]
62 version: Option<&'a str>,
63 bins: &'a [String],
65 #[serde(skip_serializing_if = "Option::is_none")]
67 owner_repo: Option<&'a str>,
68 #[serde(skip_serializing_if = "Option::is_none")]
70 host: Option<&'a str>,
71 tag_style: TagStyle,
73 channels: &'a [ChannelEvidence],
75}
76
77#[derive(Debug, Serialize)]
79struct TargetView<'a> {
80 path: &'a str,
82 #[serde(skip_serializing_if = "Option::is_none")]
84 tech: Option<&'static str>,
85 managers: Vec<ManagerRow<'a>>,
87 envrc_use_flake: bool,
89 already: &'a [Already],
91}
92
93#[derive(Debug, Serialize)]
95struct ManagerRow<'a> {
96 manager: Manager,
98 file: &'a str,
100}
101
102#[derive(Debug, Serialize)]
104struct AddReport<'a> {
105 schema: &'static str,
107 mode: &'static str,
109 kind: Kind,
111 #[serde(skip_serializing_if = "Option::is_none")]
113 manager: Option<Manager>,
114 #[serde(skip_serializing_if = "Option::is_none")]
116 manager_origin: Option<&'static str>,
117 channel: Channel,
119 landing: Mode,
121 target: &'a str,
123 source: &'a str,
125 name: &'a str,
127 version: &'a str,
129 tag: &'a str,
131 version_origin: &'static str,
133 #[serde(skip_serializing_if = "Option::is_none")]
135 file: Option<&'a str>,
136 #[serde(skip_serializing_if = "Option::is_none")]
138 file_present: Option<Presence>,
139 written: &'a [String],
142 #[serde(skip_serializing_if = "Option::is_none")]
144 refusal: Option<&'a str>,
145 fragments: &'a [Fragment],
147 #[serde(skip_serializing_if = "Option::is_none")]
149 command: Option<&'a str>,
150 #[serde(skip_serializing_if = "Option::is_none")]
152 reason: Option<&'static str>,
153 freshness: &'a str,
155 next: &'a [String],
157}
158
159pub 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
171fn 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
234fn 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
314fn 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
344fn 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
376fn 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 #[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 #[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}