Skip to main content

release_kit/commands/
init.rs

1//! `rk init`: land a technology's deterministic files into a target.
2//!
3//! A front over the direct writer: the target's evidence is gathered
4//! once, this binary's projection is computed from its embedded sources,
5//! every destination is decided by elementary ownership, and on `--apply`
6//! the writer lands the files and the receipt last under one target lock.
7//! Dry-run by default: without `--apply` the destinations and what a
8//! fresh production invocation would do with them are listed and nothing
9//! is touched; `rk stage` is the full-byte comparison surface, and
10//! `--stage` lands the answers a stage resolved. A whole-file destination
11//! present on disk with no receipt naming it is a conflict: it refuses
12//! before any write, every collision collected in one pass, unless
13//! `--replace` names it, which saves its bytes first.
14//!
15//! SATISFIES landing:a-landing-leaves-a-record
16//! SATISFIES landing:a-missing-receipt-is-a-classification
17
18use camino::Utf8Path;
19use serde::Serialize;
20
21use crate::cli::init::InitArgs;
22use crate::diagnostic::{Diagnostic, Reason};
23use crate::embedded;
24use crate::error::RkError;
25use crate::held;
26use crate::landing::apply::{self, Action, Collision, Prepared, Replaced};
27use crate::landing::manifest::{self, Provider};
28use crate::landing::{self, lock};
29use crate::output::Output;
30use crate::profile::{CapabilityRequests, GitWorkflow, ProfileSnapshot};
31use crate::stage::CapabilityNote;
32
33/// One destination and what happened to it.
34#[derive(Debug, Serialize)]
35struct FileEntry {
36    /// The destination, relative to the target.
37    path: String,
38    /// The declared ownership kind.
39    kind: &'static str,
40    /// `created`, `replaced`, `matched`, `preserved`, `drift`, `retired`,
41    /// `dropped`, or `collision` in a preview.
42    action: &'static str,
43}
44
45/// One sentinel line left for the operator.
46#[derive(Debug, Serialize)]
47struct SentinelEntry {
48    /// The landed file holding the sentinel.
49    path: String,
50    /// The 1-indexed line.
51    line: usize,
52    /// The line's text, trimmed.
53    text: String,
54}
55
56/// The machine form of a landing report.
57#[derive(Debug, Serialize)]
58struct Report {
59    /// The shape version of this document.
60    schema: &'static str,
61    /// `preview` or `apply`.
62    mode: &'static str,
63    /// The target directory.
64    target: String,
65    /// What the project is.
66    profile: ProfileSnapshot,
67    /// How topic branches reach the trunk.
68    git: GitWorkflow,
69    /// Which optional products the target requested.
70    capabilities: CapabilityRequests,
71    /// The resolved project path, where detection or `--repo` named one.
72    #[serde(skip_serializing_if = "Option::is_none")]
73    repo: Option<String>,
74    /// Every capability, in catalog order, with its status.
75    selection: Vec<CapabilityNote>,
76    /// Why the selected release automation cannot land in this release,
77    /// absent where it can or none is selected. A preview reports it and
78    /// exits 0; the apply refuses on it.
79    #[serde(skip_serializing_if = "Option::is_none")]
80    release_unavailable: Option<String>,
81    /// Why the provider's licence condition refuses this target, absent
82    /// where no condition applies or the licence satisfies it. A preview
83    /// reports it and exits 0; the apply refuses on it.
84    #[serde(skip_serializing_if = "Option::is_none")]
85    licence_refusal: Option<String>,
86    /// The Nix destinations this target could not take, each with why;
87    /// absent where nothing was withheld.
88    #[serde(skip_serializing_if = "Option::is_none")]
89    withheld: Option<Vec<landing::Withheld>>,
90    /// The destinations a production landing refuses as they stand;
91    /// absent where there is none. A preview lists them and exits 0.
92    #[serde(skip_serializing_if = "Option::is_none")]
93    collisions: Option<Vec<Collision>>,
94    /// The conflicts `--replace` named, each with where its bytes were
95    /// saved; empty where none was named.
96    replaced: Vec<Replaced>,
97    config: crate::config::Plan,
98    /// Every destination, with its kind and action.
99    files: Vec<FileEntry>,
100    /// The sentinels an apply left to fill; absent in a preview.
101    #[serde(skip_serializing_if = "Option::is_none")]
102    sentinels: Option<Vec<SentinelEntry>>,
103    /// What plausibly follows.
104    next: Vec<String>,
105}
106
107/// The withheld list a report carries.
108fn withheld_of(prepared: &Prepared) -> Option<Vec<landing::Withheld>> {
109    let withheld: Vec<landing::Withheld> = prepared
110        .projection
111        .omissions
112        .iter()
113        .map(|omission| landing::Withheld {
114            path: omission.destination.clone(),
115            reason: omission.reason.clone(),
116        })
117        .collect();
118    (!withheld.is_empty()).then_some(withheld)
119}
120
121/// Land the files for `--tech` into `--target`.
122///
123/// # Errors
124///
125/// Returns [`RkError::Usage`] for an unknown technology or pair,
126/// [`RkError::Refusal`] when the target is missing, already carries a
127/// receipt, or a destination collides, [`RkError::Missing`] when an apply
128/// resolves no repository, and [`RkError::Io`] on filesystem failure.
129pub fn run(args: &InitArgs) -> Result<(), RkError> {
130    let out = Output::new(args.json);
131    if !args.target.is_dir() {
132        return Err(RkError::refusal(
133            Diagnostic::new(
134                Reason::TargetNotFound,
135                format!(
136                    "target {} is not a directory; nothing was written",
137                    args.target
138                ),
139            )
140            .expected("an existing directory to land into")
141            .target_state("unchanged"),
142        ));
143    }
144    // An apply takes the target before it reads anything of it, the
145    // receipt and the configuration included, so the world the decisions
146    // describe is the world the writer writes. A preview holds nothing.
147    let lock = args
148        .apply
149        .then(|| lock::acquire(&args.target))
150        .transpose()?;
151    // One directory descriptor, held from here through the receipt write:
152    // every read and every write goes through it, so a root exchanged
153    // under the pathname later receives nothing.
154    // The proof's pause: the lock is held and the directory is not yet.
155    held::pause(apply::PAUSE_VAR, "locked", "proceed-locked");
156    let held = lock.as_ref().map_or_else(
157        || apply::Held::open(&args.target),
158        |lock| apply::Held::open_locked(&args.target, lock),
159    )?;
160    // The proof's pause: the target is held, and nothing has been read.
161    held::pause(apply::PAUSE_VAR, "held", "proceed-held");
162    let config = crate::config::load(held.base().as_std_path())?;
163    let purpose = if args.apply {
164        landing::Purpose::Init
165    } else {
166        landing::Purpose::Preview
167    };
168    let params = if let Some(root) = args.stage.as_deref() {
169        if answered(args) {
170            return Err(crate::stage::read::answered_twice());
171        }
172        let canonical = std::fs::canonicalize(args.target.as_std_path())?;
173        let receipt = crate::stage::read::load(root, &canonical)?;
174        let params = landing::Params::resolve(
175            held.base(),
176            &crate::stage::read::inputs(&receipt.parameters),
177            config.as_ref(),
178            None,
179            purpose,
180        )?;
181        crate::stage::read::confirm(&params, &receipt, root)?;
182        params
183    } else {
184        let inputs = landing::Inputs {
185            nix: args.nix_packaging.then_some(true),
186            reporting_policy: args.reporting_policy.then_some(true),
187            scorecard: args.scorecard.then_some(true),
188            workflow_guards: args.workflow_guards.then_some(true),
189            code_scanning: args
190                .code_scanning
191                .as_deref()
192                .map(Provider::parse)
193                .transpose()?,
194            ..args.profile.inputs()?
195        };
196        let inputs = if args.workflow_only {
197            crate::cli::profile_flags::workflow_only(inputs)?
198        } else {
199            inputs
200        };
201        landing::Params::resolve(held.base(), &inputs, config.as_ref(), None, purpose)?
202    };
203    if let Some(lock) = lock {
204        refuse_a_recorded_target(&held)?;
205        let mut prepared = apply::prepare(&held, None, &params, config.as_ref())?;
206        apply::name_replacements(&mut prepared, &args.replace)?;
207        report_moved_secrets(out, &held, &prepared)?;
208        let landed = apply::land(&held, None, &prepared, apply::Origin::Init, &lock)?;
209        drop(lock);
210        report_apply(out, args, &held, &params, &prepared, &landed)
211    } else {
212        let mut prepared = apply::prepare(&held, None, &params, config.as_ref())?;
213        apply::name_replacements(&mut prepared, &args.replace)?;
214        report_moved_secrets(out, &held, &prepared)?;
215        let repo = (params.repo() != landing::REPO_PLACEHOLDER && !params.repo().is_empty())
216            .then(|| params.repo().to_owned());
217        if params.repo() == landing::REPO_PLACEHOLDER {
218            out.frame(
219                "note: no repository detected; an apply derives the owner from --repo <path>",
220            );
221        }
222        preview(out, args, &params, repo, &prepared)
223    }
224}
225
226/// Print each secret a replaced GitHub workflow moves, before the write,
227/// so the operator stores a new one before the rendered workflow runs.
228///
229/// # Errors
230///
231/// A read of the held target that fails for a reason other than absence.
232///
233/// SATISFIES landing:ownership-is-elementary
234pub fn report_moved_secrets(
235    out: Output,
236    held: &apply::Held,
237    prepared: &Prepared,
238) -> Result<(), RkError> {
239    for moved in apply::replaced_secrets(held, prepared)? {
240        out.result_line(moved.line());
241    }
242    Ok(())
243}
244
245/// The report's shape version.
246const SCHEMA: &str = "rk.init/11";
247
248/// Whether any answer flag was given, which `--stage` refuses beside it.
249const fn answered(args: &InitArgs) -> bool {
250    !args.profile.is_empty()
251        || args.nix_packaging
252        || args.reporting_policy
253        || args.scorecard
254        || args.workflow_guards
255        || args.workflow_only
256        || args.code_scanning.is_some()
257}
258
259/// The capability notes a report carries, in catalog order.
260fn selection_of(prepared: &Prepared) -> Vec<CapabilityNote> {
261    prepared
262        .projection
263        .capabilities
264        .iter()
265        .map(|selection| CapabilityNote::of(selection, &prepared.projection))
266        .collect()
267}
268
269/// The human lines naming every capability's answer.
270pub(crate) fn describe_selection(out: Output, prepared: &Prepared) {
271    for note in selection_of(prepared) {
272        let mut line = format!("capability {}: {}", note.id, note.status);
273        if let Some(reason) = &note.reason {
274            line.push_str(" (");
275            line.push_str(reason);
276            line.push(')');
277        }
278        out.result_line(line);
279        if let Some(action) = &note.action {
280            out.result_line(format!("  action: {action}"));
281        }
282    }
283}
284
285/// List every destination with what a production landing would do, and
286/// write nothing.
287#[allow(
288    clippy::too_many_lines,
289    reason = "one pass prints the profile, the selection, every decision, and the follow-up, and splitting it would separate a line from the value behind it"
290)]
291fn preview(
292    out: Output,
293    args: &InitArgs,
294    params: &landing::Params,
295    repo: Option<String>,
296    prepared: &Prepared,
297) -> Result<(), RkError> {
298    let flags = params.canonical_flags();
299    let flags = if params.forge().is_some() && repo.is_none() {
300        format!("{flags} --repo <owner/name>")
301    } else {
302        flags
303    };
304    let capabilities = params.capability_flags();
305    let mut next = vec![format!(
306        "rk init{flags}{capabilities} --target {} --apply",
307        args.target
308    )];
309    if let Some(reason) = prepared.projection.release_unavailable() {
310        next.insert(
311            0,
312            format!("the apply refuses until the release automation resolves: {reason}"),
313        );
314    }
315    if !prepared.collisions.is_empty() {
316        next.insert(
317            0,
318            "answer each collision above: --replace <path> saves a copy of a conflict and replaces it; the apply refuses every one not named"
319                .to_owned(),
320        );
321    }
322    if let Some(reason) = prepared.projection.licence_refusal.as_deref() {
323        next.insert(
324            0,
325            format!("the apply refuses until the licence condition is answered: {reason}"),
326        );
327    }
328    if let Some(reason) = prepared.projection.driver_refusal.as_deref() {
329        next.insert(
330            0,
331            format!("the apply refuses until the project takes the release driver: {reason}"),
332        );
333    }
334    next.push(format!(
335        "rk stage --target {} stages the complete candidate for a byte comparison",
336        args.target
337    ));
338    out.result_line(format!(
339        "DRY RUN: rk init writes these files into {}; re-run with --apply",
340        args.target
341    ));
342    out.result_line(format!(
343        "profile: {}",
344        crate::commands::profile::describe(
345            params.profile(),
346            params.git(),
347            params.capabilities(),
348            params.repo()
349        )
350    ));
351    describe_selection(out, prepared);
352    for decision in &prepared.decisions {
353        out.result_line(format!(
354            "{} {}",
355            decision.action.as_str(),
356            decision.destination
357        ));
358    }
359    for collision in &prepared.collisions {
360        out.result_line(format!(
361            "collision {}: {}",
362            collision.path, collision.reason
363        ));
364    }
365    out.result_line(format!(
366        "{} {}\n{}",
367        prepared.config.action,
368        crate::config::CONFIG_PATH,
369        prepared.config.content
370    ));
371    for entry in &prepared.projection.omissions {
372        out.result_line(format!("withheld {}: {}", entry.destination, entry.reason));
373        if let Some(action) = &entry.action {
374            out.result_line(format!("  action: {action}"));
375        }
376    }
377    out.next(&next);
378    out.emit(&Report {
379        schema: SCHEMA,
380        config: prepared.config.clone(),
381        mode: "preview",
382        target: args.target.to_string(),
383        profile: params.profile().clone(),
384        git: params.git().clone(),
385        capabilities: params.capabilities().clone(),
386        repo,
387        selection: selection_of(prepared),
388        release_unavailable: prepared.projection.release_unavailable().map(str::to_owned),
389        licence_refusal: prepared.projection.licence_refusal.clone(),
390        withheld: withheld_of(prepared),
391        collisions: (!prepared.collisions.is_empty()).then(|| prepared.collisions.clone()),
392        replaced: apply::previewed_replacements(prepared),
393        files: prepared
394            .decisions
395            .iter()
396            .map(|decision| FileEntry {
397                path: decision.destination.clone(),
398                kind: decision.kind.as_str(),
399                action: decision.action.as_str(),
400            })
401            .chain(prepared.collisions.iter().map(|collision| FileEntry {
402                path: collision.path.clone(),
403                kind: landing::kind_of(&collision.path).map_or("unknown", landing::Kind::as_str),
404                action: "collision",
405            }))
406            .collect(),
407        sentinels: None,
408        next,
409    })
410}
411
412/// Report a landing the writer completed, with the judgment sentinels the
413/// operator still owes.
414#[allow(
415    clippy::too_many_arguments,
416    reason = "the report reads the landed files through the held target and names them by the path the operator gave, which are two arguments for one target"
417)]
418#[allow(
419    clippy::too_many_lines,
420    reason = "one pass reports the profile, the selection, every decision, and the sentinels the operator still owes"
421)]
422fn report_apply(
423    out: Output,
424    args: &InitArgs,
425    held: &apply::Held,
426    params: &landing::Params,
427    prepared: &Prepared,
428    landed: &apply::Landed,
429) -> Result<(), RkError> {
430    let mut file_entries = Vec::new();
431    let mut sentinels = Vec::new();
432    out.result_line(format!(
433        "profile: {}",
434        crate::commands::profile::describe(
435            params.profile(),
436            params.git(),
437            params.capabilities(),
438            params.repo()
439        )
440    ));
441    describe_selection(out, prepared);
442    for decision in &prepared.decisions {
443        out.result_line(describe(decision));
444        if !matches!(decision.action, Action::Retired | Action::Dropped) {
445            let bytes =
446                landing::read_recorded(held.base(), &decision.destination)?.unwrap_or_default();
447            collect_sentinels(
448                held.display(),
449                &decision.destination,
450                &bytes,
451                &mut sentinels,
452            );
453        }
454        file_entries.push(FileEntry {
455            path: decision.destination.clone(),
456            kind: decision.kind.as_str(),
457            action: decision.action.as_str(),
458        });
459    }
460    for entry in &prepared.projection.omissions {
461        out.result_line(format!("withheld {}: {}", entry.destination, entry.reason));
462        if let Some(action) = &entry.action {
463            out.result_line(format!("  action: {action}"));
464        }
465    }
466    out.result_line(format!(
467        "{} {}",
468        prepared.config.action,
469        crate::config::CONFIG_PATH
470    ));
471    // The required check is asked for only where a forge gate lands: a
472    // target with no gate has no check for a trunk ruleset to require.
473    let gated = prepared.projection.capabilities.iter().any(|selection| {
474        matches!(
475            selection.id,
476            crate::profile::catalog::TITLE_CHECK | crate::profile::catalog::COMMIT_CHECK
477        ) && selection.lands()
478    });
479    for (key, empty_line) in [
480        ("setup.required_check", "required_check = \"\""),
481        ("setup.bot.app_id", "app_id = \"\""),
482    ] {
483        if key == "setup.required_check" && !gated {
484            continue;
485        }
486        if let Some((index, _)) = prepared
487            .config
488            .content
489            .lines()
490            .enumerate()
491            .find(|(_, line)| line.starts_with(empty_line))
492        {
493            sentinels.push(SentinelEntry {
494                path: crate::config::CONFIG_PATH.into(),
495                line: index + 1,
496                text: format!("set {key} before forge setup"),
497            });
498        }
499    }
500    for entry in &landed.replaced {
501        if let Some(saved) = &entry.saved_to {
502            out.result_line(format!("saved {} to {saved}", entry.path));
503        }
504    }
505    out.result_line(format!("wrote {}", manifest::MANIFEST_PATH));
506    debug_assert_eq!(
507        landed.completed.last().map(String::as_str),
508        Some(manifest::MANIFEST_PATH)
509    );
510
511    if sentinels.is_empty() {
512        out.result_line("no sentinels to fill");
513    } else {
514        out.result_line("fill these sentinels before the workflow runs:");
515        for sentinel in &sentinels {
516            out.result_line(format!(
517                "{}:{}: {}",
518                sentinel.path, sentinel.line, sentinel.text
519            ));
520        }
521    }
522    let mut next = vec![
523        if sentinels.is_empty() {
524            "commit the landed files, the receipt included".to_owned()
525        } else {
526            "fill each sentinel above, then commit the landed files, the receipt included"
527                .to_owned()
528        },
529        format!("rk status --target {} reports this landing", args.target),
530    ];
531    // Only an automatic release routes to the bot-operate chapter; an
532    // external or none release has no bot to operate.
533    if params.release_mode() == crate::profile::ReleaseMode::Automatic {
534        next.push("rk method setup orders what follows".to_owned());
535    } else if params.forge().is_some() {
536        next.push("rk setup --target . previews the applicable forge steps".to_owned());
537    }
538    out.next(&next);
539    out.emit(&Report {
540        schema: SCHEMA,
541        config: prepared.config.clone(),
542        mode: "apply",
543        target: args.target.to_string(),
544        profile: params.profile().clone(),
545        git: params.git().clone(),
546        capabilities: params.capabilities().clone(),
547        repo: (!params.repo().is_empty()).then(|| params.repo().to_owned()),
548        selection: selection_of(prepared),
549        release_unavailable: None,
550        licence_refusal: prepared.projection.licence_refusal.clone(),
551        withheld: withheld_of(prepared),
552        collisions: None,
553        replaced: landed.replaced.clone(),
554        files: file_entries,
555        sentinels: Some(sentinels),
556        next,
557    })
558}
559
560/// The human line for one decision.
561pub(crate) fn describe(decision: &apply::Decision) -> String {
562    if let (Action::Preserved, Some(accepted)) = (decision.action, &decision.accepted) {
563        return format!(
564            "preserved {} (rendered, accepted drift: {}; the candidate {} since the last landing)",
565            decision.destination,
566            accepted.reason,
567            if accepted.candidate_moved {
568                "moved"
569            } else {
570                "did not move"
571            }
572        );
573    }
574    match decision.action {
575        Action::Preserved | Action::Drift => format!(
576            "{} {} ({}, target-owned)",
577            decision.action.as_str(),
578            decision.destination,
579            decision.kind.as_str()
580        ),
581        Action::Retired => format!(
582            "retired {} (no longer produced and unedited since it landed; the landing removes it)",
583            decision.destination
584        ),
585        Action::Dropped => format!(
586            "dropped {} (no longer produced; the target's own from this landing)",
587            decision.destination
588        ),
589        Action::Matched => format!(
590            "matched {} (already holds the candidate's bytes)",
591            decision.destination
592        ),
593        Action::Created | Action::Replaced => {
594            format!("{} {}", decision.action.as_str(), decision.destination)
595        }
596    }
597}
598
599/// A re-landing over an existing receipt is `rk upgrade`'s job, not a
600/// second `rk init`.
601fn refuse_a_recorded_target(held: &apply::Held) -> Result<(), RkError> {
602    if landing::manifest::load(held.base())?.is_none() {
603        return Ok(());
604    }
605    let target = held.display();
606    Err(RkError::refusal(
607        Diagnostic::new(
608            Reason::StateDrift,
609            format!(
610                "{target} already carries {}, and nothing was written",
611                manifest::MANIFEST_PATH
612            ),
613        )
614        .expected("a target without a landing receipt")
615        .action(format!(
616            "rk upgrade --target {target} takes it to this binary's projection"
617        ))
618        .target_state("unchanged"),
619    ))
620}
621
622/// Collect every judgment-sentinel line one landed file carries, so
623/// nothing stays half-configured silently.
624fn collect_sentinels(
625    target: &Utf8Path,
626    destination: &str,
627    bytes: &[u8],
628    found: &mut Vec<SentinelEntry>,
629) {
630    let text = String::from_utf8_lossy(bytes);
631    for (idx, line) in text.lines().enumerate() {
632        if line.contains(embedded::SENTINEL) {
633            found.push(SentinelEntry {
634                path: target.join(destination).to_string(),
635                line: idx + 1,
636                text: line.trim().to_owned(),
637            });
638        }
639    }
640}
641
642#[cfg(test)]
643mod tests {
644    use super::{FileEntry, Report, SentinelEntry};
645    use crate::landing::CheckoutMode;
646    use crate::landing::Integration;
647    use crate::profile::{
648        CapabilityRequests, GitWorkflow, ProfileSnapshot, ReleaseIntent, ReleaseMode,
649    };
650    use crate::stage::CapabilityNote;
651
652    /// The complete `rk.init/11` shape, held by snapshot in both modes: a
653    /// field rename or removal fails here and becomes a schema-version
654    /// bump instead of a silent parser break at some agent.
655    #[test]
656    fn the_init_report_schema_snapshot_holds() {
657        let apply = Report {
658            schema: super::SCHEMA,
659            config: crate::config::Plan {
660                action: "added",
661                changes: vec![],
662                content: "schema_version = 2\n".into(),
663            },
664            mode: "apply",
665            target: "/tmp/t".into(),
666            profile: ProfileSnapshot {
667                technologies: vec!["rust".into()],
668                forge: Some("github".into()),
669                release: ReleaseIntent {
670                    mode: ReleaseMode::Automatic,
671                    driver: Some("rust".into()),
672                    style: Some(crate::landing::Style::Trunk),
673                    line_prefix: Some("release/".into()),
674                    registry: None,
675                    build: None,
676                },
677            },
678            git: GitWorkflow {
679                trunk: "master".into(),
680                checkout_mode: CheckoutMode::LinkedWorktree,
681                integration: Integration::Local,
682                merge_form: crate::landing::MergeForm::Squash,
683            },
684            capabilities: CapabilityRequests {
685                nix_packaging: true,
686                reporting_policy: true,
687                scorecard: true,
688                code_scanning: Some(crate::landing::Provider::CodeQl),
689                workflow_guards: Some(false),
690            },
691            repo: Some("acme/widget".into()),
692            selection: vec![CapabilityNote {
693                id: "git.guards".into(),
694                status: "selected".into(),
695                reason: None,
696                action: None,
697                destinations: vec!["AGENTS.md".into()],
698            }],
699            release_unavailable: None,
700            licence_refusal: None,
701            withheld: Some(vec![crate::landing::Withheld {
702                path: "flake.nix".into(),
703                reason: "the target already carries flake.nix".into(),
704            }]),
705            collisions: None,
706            replaced: vec![],
707            files: vec![FileEntry {
708                path: "release-plz.toml".into(),
709                kind: "seeded",
710                action: "created",
711            }],
712            sentinels: Some(vec![SentinelEntry {
713                path: "/tmp/t/release-plz.toml".into(),
714                line: 3,
715                text: "# TODO(release-kit): keep false for a binary-only crate".into(),
716            }]),
717            next: vec!["commit the landed files, the receipt included".into()],
718        };
719        assert_eq!(
720            serde_json::to_string(&apply).expect("a report serializes"),
721            r##"{"schema":"rk.init/11","mode":"apply","target":"/tmp/t","profile":{"technologies":["rust"],"forge":"github","release":{"mode":"automatic","driver":"rust","style":"trunk","line_prefix":"release/"}},"git":{"trunk":"master","checkout_mode":"linked-worktree","integration":"local","merge_form":"squash"},"capabilities":{"nix_packaging":true,"reporting_policy":true,"scorecard":true,"code_scanning":"codeql","workflow_guards":false},"repo":"acme/widget","selection":[{"id":"git.guards","status":"selected","destinations":["AGENTS.md"]}],"withheld":[{"path":"flake.nix","reason":"the target already carries flake.nix"}],"replaced":[],"config":{"action":"added","changes":[],"content":"schema_version = 2\n"},"files":[{"path":"release-plz.toml","kind":"seeded","action":"created"}],"sentinels":[{"path":"/tmp/t/release-plz.toml","line":3,"text":"# TODO(release-kit): keep false for a binary-only crate"}],"next":["commit the landed files, the receipt included"]}"##
722        );
723        let preview = Report {
724            sentinels: None,
725            repo: None,
726            mode: "preview",
727            capabilities: CapabilityRequests {
728                nix_packaging: false,
729                reporting_policy: false,
730                scorecard: false,
731                code_scanning: None,
732                workflow_guards: Some(false),
733            },
734            selection: vec![],
735            release_unavailable: Some(
736                "the release automation at (python, gitlab) has no landable files".to_owned(),
737            ),
738            licence_refusal: Some("the target's Cargo.toml declares no license field".to_owned()),
739            withheld: None,
740            collisions: Some(vec![super::Collision {
741                path: "SECURITY.md".into(),
742                reason: "exists, and no receipt attributes it to release-kit".into(),
743                replaceable: true,
744            }]),
745            replaced: vec![super::Replaced {
746                path: "SECURITY.md".into(),
747                saved_to: None,
748            }],
749            ..apply
750        };
751        assert_eq!(
752            serde_json::to_string(&preview).expect("a report serializes"),
753            r#"{"schema":"rk.init/11","mode":"preview","target":"/tmp/t","profile":{"technologies":["rust"],"forge":"github","release":{"mode":"automatic","driver":"rust","style":"trunk","line_prefix":"release/"}},"git":{"trunk":"master","checkout_mode":"linked-worktree","integration":"local","merge_form":"squash"},"capabilities":{"nix_packaging":false,"reporting_policy":false,"scorecard":false,"workflow_guards":false},"selection":[],"release_unavailable":"the release automation at (python, gitlab) has no landable files","licence_refusal":"the target's Cargo.toml declares no license field","collisions":[{"path":"SECURITY.md","reason":"exists, and no receipt attributes it to release-kit","replaceable":true}],"replaced":[{"path":"SECURITY.md","saved_to":null}],"config":{"action":"added","changes":[],"content":"schema_version = 2\n"},"files":[{"path":"release-plz.toml","kind":"seeded","action":"created"}],"next":["commit the landed files, the receipt included"]}"#,
754            "a preview omits the sentinels, the unresolved repo, and an empty withheld list rather than serializing null"
755        );
756    }
757}