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