Skip to main content

release_kit/commands/
adopt.rs

1//! `rk adopt`: a pre-record target becomes a recorded one.
2//!
3//! A front over the direct writer: this binary's projection is computed
4//! exactly as `rk init` would land it; every `rendered` destination must
5//! match it byte for byte, and one mismatch refuses the whole adoption
6//! listing every mismatch and every missing expected file in one run.
7//! On `--apply` the writer lands the configuration and then the receipt,
8//! last, inside `.release-kit/` and nothing else. Blessing whatever is on
9//! disk would launder arbitrary drift into release-kit ownership, so
10//! nothing here ever takes the disk as the candidate, and no target file
11//! is ever changed: not a byte, not a mode, not a sentinel.
12//!
13//! SATISFIES landing:an-adoption-writes-the-record-and-nothing-else
14//! SATISFIES landing:a-missing-receipt-is-a-classification
15
16use serde::Serialize;
17
18use crate::cli::adopt::AdoptArgs;
19use crate::diagnostic::{Diagnostic, Reason};
20use crate::error::RkError;
21use crate::held;
22use crate::landing::apply::{self, Prepared};
23use crate::landing::manifest::{self, Style, Workflow};
24use crate::landing::{self, Kind, lock};
25use crate::output::Output;
26use crate::projection::Placement;
27use crate::release::EmbeddedReleaseSource;
28
29/// One verified destination.
30#[derive(Debug, Serialize)]
31struct FileEntry {
32    /// The destination, relative to the target.
33    path: String,
34    /// The declared ownership kind.
35    kind: &'static str,
36    /// `matches`, `differs` for a seeded file, or `state`.
37    action: &'static str,
38}
39
40/// The machine form of an adoption report.
41#[derive(Debug, Serialize)]
42struct Report {
43    /// The shape version of this document.
44    schema: &'static str,
45    /// `preview` or `apply`.
46    mode: &'static str,
47    /// The target directory.
48    target: String,
49    /// The technology whose projection was verified.
50    tech: String,
51    /// The forge whose projection was verified.
52    forge: String,
53    /// The parameter the candidate was rendered under.
54    repo: String,
55    /// The working-copy mode the candidate was rendered under and the
56    /// receipt carries.
57    workflow: &'static str,
58    style: &'static str,
59    /// Whether the receipt carries the Nix capability.
60    nix: bool,
61    /// The Nix destinations excluded from the candidate, each with why;
62    /// absent where nothing was withheld.
63    #[serde(skip_serializing_if = "Option::is_none")]
64    withheld: Option<Vec<landing::Withheld>>,
65    config: crate::config::Plan,
66    /// Every destination, with its verification result.
67    files: Vec<FileEntry>,
68    /// What plausibly follows.
69    next: Vec<String>,
70}
71
72/// Verify the target against the rendered candidate and, on `--apply`,
73/// write the config and receipt inside `.release-kit/`.
74///
75/// # Errors
76///
77/// Returns a refusal for a target already carrying a receipt, for any
78/// `rendered` mismatch or missing expected file, listing every one in
79/// one run, and [`RkError::Missing`] where detection resolves no
80/// technology, forge, or repository and no flag covers the gap.
81pub fn run(args: &AdoptArgs) -> Result<(), RkError> {
82    let out = Output::new(args.json);
83    if !args.target.is_dir() {
84        return Err(RkError::missing(
85            Diagnostic::new(
86                Reason::TargetNotFound,
87                format!("target {} is not a directory", args.target),
88            )
89            .expected("an existing repository to adopt"),
90        ));
91    }
92    // An apply takes the target before it reads anything of it, the
93    // receipt and the configuration included, so the bytes verified are
94    // the bytes the receipt digests. A preview holds nothing.
95    let lock = args
96        .apply
97        .then(|| lock::acquire(&args.target))
98        .transpose()?;
99    // One directory descriptor, held from here through the receipt write:
100    // every read and every write goes through it, so a root exchanged
101    // under the pathname later receives nothing.
102    // The proof's pause: the lock is held and the directory is not yet.
103    held::pause(apply::PAUSE_VAR, "locked", "proceed-locked");
104    let held = lock.as_ref().map_or_else(
105        || apply::Held::open(&args.target),
106        |lock| apply::Held::open_locked(&args.target, lock),
107    )?;
108    // The proof's pause: the target is held, and nothing has been read.
109    held::pause(apply::PAUSE_VAR, "held", "proceed-held");
110    if landing::manifest::load(held.base())?.is_some() {
111        return Err(RkError::refusal(
112            Diagnostic::new(
113                Reason::StateDrift,
114                format!(
115                    "{} already carries {}; it needs no adoption",
116                    args.target,
117                    manifest::MANIFEST_PATH
118                ),
119            )
120            .expected("a target without a landing receipt")
121            .action(format!(
122                "rk upgrade --target {} takes it to this binary's projection",
123                args.target
124            ))
125            .target_state("unchanged"),
126        ));
127    }
128    let config = crate::config::load(held.base().as_std_path())?;
129    let params = landing::Params::resolve(
130        &EmbeddedReleaseSource,
131        held.base(),
132        &landing::Inputs {
133            tech: args.tech.as_deref(),
134            forge: args.forge.as_deref(),
135            repo: args.repo.as_deref(),
136            workflow: args.workflow.as_deref().map(Workflow::parse).transpose()?,
137            style: args.style.as_deref().map(Style::parse).transpose()?,
138            nix: args.nix.then_some(true),
139        },
140        config.as_ref(),
141        None,
142        landing::Purpose::Adopt,
143    )?;
144    let workflow = params.workflow();
145    let style = params
146        .style()
147        .ok_or_else(|| RkError::Usage("landing style is unresolved".into()))?;
148
149    let mut prepared = apply::prepare(&held, None, &params, config.as_ref())?;
150    let files = verify(&held, workflow, &prepared)?;
151    // Every destination verified, so what the decision pass read as an
152    // unattributed whole file is a file the agent brought to the
153    // projection: the adoption records it and writes nothing else.
154    prepared.collisions.clear();
155
156    for file in &files {
157        out.result_line(match file.action {
158            "differs" => format!("differs {} (seeded, target-owned)", file.path),
159            action => format!("{action} {}", file.path),
160        });
161    }
162    for entry in &prepared.projection.omissions {
163        out.result_line(format!("withheld {}: {}", entry.destination, entry.reason));
164    }
165
166    if let Some(lock) = &lock {
167        apply::land(&held, None, &prepared, apply::Origin::Adopt, lock)?;
168        out.result_line(format!("wrote {}", manifest::MANIFEST_PATH));
169    }
170    drop(lock);
171    report(out, args, &params, style, &prepared, files)
172}
173
174/// The report of a verified target, and of the receipt where one was
175/// written.
176fn report(
177    out: Output,
178    args: &AdoptArgs,
179    params: &landing::Params,
180    style: Style,
181    prepared: &Prepared,
182    files: Vec<FileEntry>,
183) -> Result<(), RkError> {
184    let tech = params.tech().to_owned();
185    let repo = params.repo().to_owned();
186    let workflow = params.workflow();
187    let next = if args.apply {
188        vec![
189            "commit the config and the receipt".to_owned(),
190            format!("rk status --target {} reports this landing", args.target),
191        ]
192    } else {
193        vec![format!(
194            "rk adopt --tech {tech} --forge {} --repo {repo} --workflow {} --style {}{} --target {} --apply writes the config and the receipt inside .release-kit/",
195            params.forge(),
196            workflow.as_str(),
197            style.as_str(),
198            if params.nix() { " --nix" } else { "" },
199            args.target
200        )]
201    };
202    out.result_line(format!(
203        "{} {}\n{}",
204        prepared.config.action,
205        crate::config::CONFIG_PATH,
206        prepared.config.content
207    ));
208    out.next(&next);
209    out.emit(&Report {
210        schema: "rk.adopt/7",
211        config: prepared.config.clone(),
212        mode: if args.apply { "apply" } else { "preview" },
213        target: args.target.to_string(),
214        tech,
215        forge: params.forge().to_owned(),
216        repo,
217        workflow: workflow.as_str(),
218        style: style.as_str(),
219        nix: params.nix(),
220        withheld: {
221            let withheld: Vec<landing::Withheld> = prepared
222                .projection
223                .omissions
224                .iter()
225                .map(|omission| landing::Withheld {
226                    path: omission.destination.clone(),
227                    reason: omission.reason.clone(),
228                })
229                .collect();
230            (!withheld.is_empty()).then_some(withheld)
231        },
232        files,
233        next,
234    })
235}
236
237/// The verification pass: every candidate checked against the disk,
238/// every failure collected before the one refusal, so an operator
239/// resolves everything and re-runs once.
240fn verify(
241    held: &apply::Held,
242    workflow: Workflow,
243    prepared: &Prepared,
244) -> Result<Vec<FileEntry>, RkError> {
245    let target = held.display();
246    let mut mismatches: Vec<String> = Vec::new();
247    let mut missing: Vec<String> = Vec::new();
248    let mut files = Vec::new();
249    // An ill-formed marked document lists beside the mismatches rather
250    // than refusing alone, so one run still names everything unadoptable.
251    let defects: Vec<String> = prepared
252        .projection
253        .collisions
254        .iter()
255        .map(|collision| collision.reason.clone())
256        .collect();
257    for candidate in &prepared.projection.candidates {
258        let path = held.base().join(&candidate.destination);
259        let regular = std::fs::symlink_metadata(path.as_std_path())
260            .is_ok_and(|metadata| metadata.is_file())
261            || !path.exists();
262        if !regular {
263            mismatches.push(format!("{} (is not a regular file)", candidate.destination));
264            continue;
265        }
266        let current = landing::read_recorded(held.base(), &candidate.destination)?;
267        let Some(current) = current else {
268            // A block-placed artifact reads as absent from a file that
269            // exists; the operator's remedy differs, so the label must.
270            let label = if path.exists() {
271                format!("{} (carries no release-kit block)", candidate.destination)
272            } else {
273                format!("{} (expected and missing)", candidate.destination)
274            };
275            missing.push(label);
276            continue;
277        };
278        let expected: &[u8] = match candidate.placement {
279            Placement::Whole => &candidate.bytes,
280            Placement::Region { .. } => candidate.region.as_deref().unwrap_or(&candidate.bytes),
281        };
282        let action = match candidate.kind {
283            Kind::Rendered | Kind::Seeded if current == expected => "matches",
284            Kind::Rendered => {
285                mismatches.push(format!(
286                    "{} (differs from the rendered candidate)",
287                    candidate.destination
288                ));
289                "differs"
290            }
291            Kind::Seeded => "differs",
292            Kind::State => "state",
293        };
294        files.push(FileEntry {
295            path: candidate.destination.clone(),
296            kind: candidate.kind.as_str(),
297            action,
298        });
299    }
300    if mismatches.is_empty() && missing.is_empty() && defects.is_empty() {
301        return Ok(files);
302    }
303    let listed: Vec<String> = mismatches
304        .iter()
305        .cloned()
306        .chain(missing.iter().cloned())
307        .chain(defects.iter().cloned())
308        .collect();
309    Err(RkError::refusal(
310        Diagnostic::new(
311            Reason::StateDrift,
312            format!(
313                "this target is not adoptable as-is, and no receipt was written: {}",
314                listed.join(", ")
315            ),
316        )
317        .expected(format!(
318            "every rendered destination matching the {} candidate, byte for byte",
319            workflow.as_str()
320        ))
321        .action(format!(
322            "align first: rk stage --target {} stages the candidate for a byte comparison, and the rk-setup skill carries the migration that brings each destination to it; then re-run, or select the other candidate with --workflow or --style{}",
323            target,
324            // A policy the target wrote its own contact into is the one
325            // mismatch a committed answer resolves rather than an edit:
326            // naming the keys turns a dead end into the next step.
327            if mismatches.iter().any(|path| path.starts_with("SECURITY.md ")) {
328                ". SECURITY.md states two facts a target owns: set security.contact and security.response in .release-kit/config.toml to the wording this policy already carries, and the candidate matches"
329            } else {
330                ""
331            }
332        ))
333        .target_state("unchanged"),
334    ))
335}
336
337#[cfg(test)]
338mod tests {
339    use super::{FileEntry, Report};
340
341    /// The complete `rk.adopt/7` shape, held by snapshot.
342    #[test]
343    fn the_adopt_report_schema_snapshot_holds() {
344        let report = Report {
345            schema: "rk.adopt/7",
346            config: crate::config::Plan {
347                action: "added",
348                changes: vec![],
349                content: "schema_version = 1\n".into(),
350            },
351            mode: "apply",
352            target: "/tmp/t".into(),
353            tech: "rust".into(),
354            forge: "github".into(),
355            repo: "acme/widget".into(),
356            workflow: "branches",
357            style: "trunk",
358            nix: false,
359            withheld: None,
360            files: vec![FileEntry {
361                path: "release-plz.toml".into(),
362                kind: "seeded",
363                action: "differs",
364            }],
365            next: vec!["commit the config and the receipt".into()],
366        };
367        assert_eq!(
368            serde_json::to_string(&report).expect("a report serializes"),
369            r#"{"schema":"rk.adopt/7","mode":"apply","target":"/tmp/t","tech":"rust","forge":"github","repo":"acme/widget","workflow":"branches","style":"trunk","nix":false,"config":{"action":"added","changes":[],"content":"schema_version = 1\n"},"files":[{"path":"release-plz.toml","kind":"seeded","action":"differs"}],"next":["commit the config and the receipt"]}"#
370        );
371    }
372}