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 the operator did not name
6//! refuses the whole adoption, listing every mismatch and every missing
7//! expected file in one run. A mismatch or a missing file `--replace`
8//! names is a conflict the operator answered: on `--apply` its bytes are
9//! saved first, then the candidate is written. The writer then lands the
10//! configuration and the receipt, last. Blessing whatever is on disk
11//! would launder arbitrary drift into release-kit ownership, so nothing
12//! here ever takes the disk as the candidate, and no file outside
13//! `.release-kit/` changes unless the operator named it.
14//!
15//! SATISFIES landing:an-adoption-writes-the-record-and-nothing-else
16//! SATISFIES landing:a-missing-receipt-is-a-classification
17
18use serde::Serialize;
19
20use crate::cli::adopt::AdoptArgs;
21use crate::diagnostic::{Diagnostic, Reason};
22use crate::error::RkError;
23use crate::held;
24use crate::landing::apply::{self, Adoption, Prepared, Replaced};
25use crate::landing::manifest::{self, CheckoutMode, Provider};
26use crate::landing::{self, lock};
27use crate::output::Output;
28use crate::profile::{CapabilityRequests, GitWorkflow, ProfileSnapshot};
29use crate::stage::CapabilityNote;
30
31/// One verified destination.
32#[derive(Debug, Serialize)]
33struct FileEntry {
34    /// The destination, relative to the target.
35    path: String,
36    /// The declared ownership kind.
37    kind: &'static str,
38    /// `matches`, `differs` for a seeded file, or `state`.
39    action: &'static str,
40}
41
42/// The machine form of an adoption report.
43#[derive(Debug, Serialize)]
44struct Report {
45    /// The shape version of this document.
46    schema: &'static str,
47    /// `preview` or `apply`.
48    mode: &'static str,
49    /// The target directory.
50    target: String,
51    /// What the project is, as the candidate was rendered under it.
52    profile: ProfileSnapshot,
53    /// How topic branches reach the trunk.
54    git: GitWorkflow,
55    /// Which optional products the record carries.
56    capabilities: CapabilityRequests,
57    /// The parameter the candidate was rendered under.
58    repo: String,
59    /// Every capability, in catalog order, with its status.
60    selection: Vec<CapabilityNote>,
61    /// Why the selected release automation cannot land in this release,
62    /// absent where it can or none is selected.
63    #[serde(skip_serializing_if = "Option::is_none")]
64    release_unavailable: Option<String>,
65    /// Why the provider's licence condition refuses this target, absent
66    /// where no condition applies or the licence satisfies it. A preview
67    /// reports it and exits 0; the apply refuses on it.
68    #[serde(skip_serializing_if = "Option::is_none")]
69    licence_refusal: Option<String>,
70    /// The Nix destinations excluded from the candidate, each with why;
71    /// absent where nothing was withheld.
72    #[serde(skip_serializing_if = "Option::is_none")]
73    withheld: Option<Vec<landing::Withheld>>,
74    config: crate::config::Plan,
75    /// Every destination, with its verification result.
76    files: Vec<FileEntry>,
77    /// The conflicts `--replace` named, each with where its bytes were
78    /// saved; empty where none was named.
79    replaced: Vec<Replaced>,
80    /// What plausibly follows.
81    next: Vec<String>,
82}
83
84/// Verify the target against the rendered candidate and, on `--apply`,
85/// write the config and receipt inside `.release-kit/`.
86///
87/// # Errors
88///
89/// Returns a refusal for a target already carrying a receipt, for any
90/// `rendered` mismatch or missing expected file, listing every one in
91/// one run, and [`RkError::Missing`] where detection resolves no
92/// technology, forge, or repository and no flag covers the gap.
93#[allow(
94    clippy::too_many_lines,
95    reason = "one pass holds the target, resolves the answers once, verifies, and lands"
96)]
97pub fn run(args: &AdoptArgs) -> Result<(), RkError> {
98    let out = Output::new(args.json);
99    if !args.target.is_dir() {
100        return Err(RkError::missing(
101            Diagnostic::new(
102                Reason::TargetNotFound,
103                format!("target {} is not a directory", args.target),
104            )
105            .expected("an existing repository to adopt"),
106        ));
107    }
108    // An apply takes the target before it reads anything of it, the
109    // receipt and the configuration included, so the bytes verified are
110    // the bytes the receipt digests. A preview holds nothing.
111    let lock = args
112        .apply
113        .then(|| lock::acquire(&args.target))
114        .transpose()?;
115    // One directory descriptor, held from here through the receipt write:
116    // every read and every write goes through it, so a root exchanged
117    // under the pathname later receives nothing.
118    // The proof's pause: the lock is held and the directory is not yet.
119    held::pause(apply::PAUSE_VAR, "locked", "proceed-locked");
120    let held = lock.as_ref().map_or_else(
121        || apply::Held::open(&args.target),
122        |lock| apply::Held::open_locked(&args.target, lock),
123    )?;
124    // The proof's pause: the target is held, and nothing has been read.
125    held::pause(apply::PAUSE_VAR, "held", "proceed-held");
126    if landing::manifest::load(held.base())?.is_some() {
127        return Err(RkError::refusal(
128            Diagnostic::new(
129                Reason::StateDrift,
130                format!(
131                    "{} already carries {}; it needs no adoption",
132                    args.target,
133                    manifest::MANIFEST_PATH
134                ),
135            )
136            .expected("a target without a landing receipt")
137            .action(format!(
138                "rk upgrade --target {} takes it to this binary's projection",
139                args.target
140            ))
141            .target_state("unchanged"),
142        ));
143    }
144    let config = crate::config::load(held.base().as_std_path())?;
145    let staged = args
146        .stage
147        .as_deref()
148        .map(|root| {
149            if answered(args) {
150                return Err(crate::stage::read::answered_twice());
151            }
152            let canonical = std::fs::canonicalize(args.target.as_std_path())?;
153            crate::stage::read::load(root, &canonical).map(|receipt| (root, receipt))
154        })
155        .transpose()?;
156    let staged_inputs = staged
157        .as_ref()
158        .map(|(_, receipt)| crate::stage::read::inputs(&receipt.parameters));
159    let flag_inputs = landing::Inputs {
160        nix: args.nix_packaging.then_some(true),
161        reporting_policy: args.reporting_policy.then_some(true),
162        scorecard: args.scorecard.then_some(true),
163        workflow_guards: args.workflow_guards.then_some(true),
164        code_scanning: args
165            .code_scanning
166            .as_deref()
167            .map(Provider::parse)
168            .transpose()?,
169        ..args.profile.inputs()?
170    };
171    let params = landing::Params::resolve(
172        held.base(),
173        staged_inputs.as_ref().unwrap_or(&flag_inputs),
174        config.as_ref(),
175        None,
176        landing::Purpose::Adopt,
177    )?;
178    if let Some((root, receipt)) = &staged {
179        crate::stage::read::confirm(&params, receipt, root)?;
180    }
181    let checkout_mode = params.checkout_mode();
182
183    let mut prepared = apply::prepare(&held, None, &params, config.as_ref())?;
184    let files = verify(&held, checkout_mode, &mut prepared, &args.replace)?;
185    // Every destination verified, so what the decision pass read as an
186    // unattributed whole file is a file the agent brought to the
187    // projection: the adoption records it and writes nothing else.
188    prepared.collisions.clear();
189
190    out.result_line(format!(
191        "profile: {}",
192        crate::commands::profile::describe(
193            params.profile(),
194            params.git(),
195            params.capabilities(),
196            params.repo()
197        )
198    ));
199    crate::commands::init::describe_selection(out, &prepared);
200    for file in &files {
201        out.result_line(match file.action {
202            "differs" => format!("differs {} (seeded, target-owned)", file.path),
203            action => format!("{action} {}", file.path),
204        });
205    }
206    for entry in &prepared.projection.omissions {
207        out.result_line(format!("withheld {}: {}", entry.destination, entry.reason));
208    }
209
210    let replaced = if let Some(lock) = &lock {
211        let landed = apply::land(&held, None, &prepared, apply::Origin::Adopt, lock)?;
212        for entry in &landed.replaced {
213            if let Some(saved) = &entry.saved_to {
214                out.result_line(format!("saved {} to {saved}", entry.path));
215            }
216        }
217        out.result_line(format!("wrote {}", manifest::MANIFEST_PATH));
218        landed.replaced
219    } else {
220        apply::previewed_replacements(&prepared)
221    };
222    drop(lock);
223    report(out, args, &params, &prepared, files, replaced)
224}
225
226/// The report's shape version.
227const SCHEMA: &str = "rk.adopt/11";
228
229/// Whether any answer flag was given, which `--stage` refuses beside it.
230const fn answered(args: &AdoptArgs) -> bool {
231    !args.profile.is_empty()
232        || args.nix_packaging
233        || args.reporting_policy
234        || args.scorecard
235        || args.workflow_guards
236        || args.code_scanning.is_some()
237}
238
239/// The report of a verified target, and of the receipt where one was
240/// written.
241fn report(
242    out: Output,
243    args: &AdoptArgs,
244    params: &landing::Params,
245    prepared: &Prepared,
246    files: Vec<FileEntry>,
247    replaced: Vec<Replaced>,
248) -> Result<(), RkError> {
249    let repo = params.repo().to_owned();
250    let mut next = if args.apply {
251        vec![
252            "commit the config and the receipt".to_owned(),
253            format!("rk status --target {} reports this landing", args.target),
254        ]
255    } else {
256        vec![format!(
257            "rk adopt{}{} --target {} --apply writes the config and the receipt inside .release-kit/",
258            params.canonical_flags(),
259            params.capability_flags(),
260            args.target
261        )]
262    };
263    if let Some(reason) = prepared.projection.release_unavailable() {
264        next.insert(
265            0,
266            format!("the apply refuses until the release automation resolves: {reason}"),
267        );
268    }
269    if let Some(reason) = prepared.projection.licence_refusal.as_deref() {
270        next.insert(
271            0,
272            format!("the apply refuses until the licence condition is answered: {reason}"),
273        );
274    }
275    out.result_line(format!(
276        "{} {}\n{}",
277        prepared.config.action,
278        crate::config::CONFIG_PATH,
279        prepared.config.content
280    ));
281    out.next(&next);
282    out.emit(&Report {
283        schema: SCHEMA,
284        config: prepared.config.clone(),
285        mode: if args.apply { "apply" } else { "preview" },
286        target: args.target.to_string(),
287        profile: params.profile().clone(),
288        git: params.git().clone(),
289        capabilities: params.capabilities().clone(),
290        repo,
291        selection: prepared
292            .projection
293            .capabilities
294            .iter()
295            .map(|selection| CapabilityNote::of(selection, &prepared.projection))
296            .collect(),
297        release_unavailable: prepared.projection.release_unavailable().map(str::to_owned),
298        licence_refusal: prepared.projection.licence_refusal.clone(),
299        withheld: {
300            let withheld: Vec<landing::Withheld> = prepared
301                .projection
302                .omissions
303                .iter()
304                .map(|omission| landing::Withheld {
305                    path: omission.destination.clone(),
306                    reason: omission.reason.clone(),
307                })
308                .collect();
309            (!withheld.is_empty()).then_some(withheld)
310        },
311        files,
312        replaced,
313        next,
314    })
315}
316
317/// The verification pass: every candidate checked against the disk,
318/// every failure collected before the one refusal, so an operator
319/// resolves everything and re-runs once. A rendered mismatch or a missing
320/// expected file `replace` names is the operator's answered conflict: it
321/// joins the replacements instead of the refusal.
322///
323/// SATISFIES landing:an-adoption-writes-the-record-and-nothing-else
324#[allow(
325    clippy::too_many_lines,
326    reason = "one pass judges every candidate, the named conflicts, and the one refusal"
327)]
328fn verify(
329    held: &apply::Held,
330    checkout_mode: CheckoutMode,
331    prepared: &mut Prepared,
332    replace: &[String],
333) -> Result<Vec<FileEntry>, RkError> {
334    let named = |destination: &str| replace.iter().any(|path| path == destination);
335    let mut answered: Vec<String> = Vec::new();
336    let target = held.display();
337    let mut mismatches: Vec<String> = Vec::new();
338    let mut missing: Vec<String> = Vec::new();
339    let mut files = Vec::new();
340    // An ill-formed marked document lists beside the mismatches rather
341    // than refusing alone, so one run still names everything unadoptable.
342    let defects: Vec<String> = prepared
343        .projection
344        .collisions
345        .iter()
346        .map(|collision| collision.reason.clone())
347        .collect();
348    for candidate in &prepared.projection.candidates {
349        let judged = apply::judge_adoption(held, candidate)?;
350        let answers = judged.replaceable(candidate.kind) && named(&candidate.destination);
351        let action = match judged {
352            Adoption::Irregular => {
353                mismatches.push(format!("{} (is not a regular file)", candidate.destination));
354                continue;
355            }
356            Adoption::Missing { exists } => {
357                if answers {
358                    answered.push(candidate.destination.clone());
359                    files.push(FileEntry {
360                        path: candidate.destination.clone(),
361                        kind: candidate.kind.as_str(),
362                        action: "replaced",
363                    });
364                    continue;
365                }
366                // A block-placed artifact reads as absent from a file that
367                // exists; the operator's remedy differs, so the label must.
368                missing.push(if exists {
369                    format!("{} (carries no release-kit block)", candidate.destination)
370                } else {
371                    format!("{} (expected and missing)", candidate.destination)
372                });
373                continue;
374            }
375            Adoption::Matches => "matches",
376            Adoption::Differs if answers => {
377                answered.push(candidate.destination.clone());
378                "replaced"
379            }
380            Adoption::Differs => {
381                mismatches.push(format!(
382                    "{} (differs from the rendered candidate)",
383                    candidate.destination
384                ));
385                "differs"
386            }
387            Adoption::Tuned => "differs",
388            Adoption::State => "state",
389        };
390        files.push(FileEntry {
391            path: candidate.destination.clone(),
392            kind: candidate.kind.as_str(),
393            action,
394        });
395    }
396    if let Some(stray) = replace.iter().find(|path| !answered.contains(path)) {
397        return Err(RkError::Usage(format!(
398            "--replace {stray} names no conflict this adoption has; a conflict is a rendered destination that differs from the candidate or is missing"
399        )));
400    }
401    if mismatches.is_empty() && missing.is_empty() && defects.is_empty() {
402        prepared.replacements = answered;
403        return Ok(files);
404    }
405    let listed: Vec<String> = mismatches
406        .iter()
407        .cloned()
408        .chain(missing.iter().cloned())
409        .chain(defects.iter().cloned())
410        .collect();
411    Err(RkError::refusal(
412        Diagnostic::new(
413            Reason::StateDrift,
414            format!(
415                "this target is not adoptable as-is, and no receipt was written: {}",
416                listed.join(", ")
417            ),
418        )
419        .expected(format!(
420            "every rendered destination matching the {} candidate, byte for byte",
421            checkout_mode.as_str()
422        ))
423        .action(format!(
424            "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 --checkout-mode, --integration, or --release-style{}",
425            target,
426            // A policy the target wrote its own contact into is the one
427            // mismatch a committed answer resolves rather than an edit:
428            // naming the keys turns a dead end into the next step.
429            if mismatches.iter().any(|path| path.starts_with("SECURITY.md ")) {
430                ". 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"
431            } else {
432                ""
433            }
434        ))
435        .target_state("unchanged"),
436    ))
437}
438
439#[cfg(test)]
440mod tests {
441    use super::{FileEntry, Report};
442    use crate::landing::{CheckoutMode, Integration};
443    use crate::profile::{
444        CapabilityRequests, GitWorkflow, ProfileSnapshot, ReleaseIntent, ReleaseMode,
445    };
446
447    /// The complete `rk.adopt/11` shape, held by snapshot.
448    #[test]
449    fn the_adopt_report_schema_snapshot_holds() {
450        let report = Report {
451            schema: super::SCHEMA,
452            config: crate::config::Plan {
453                action: "added",
454                changes: vec![],
455                content: "schema_version = 2\n".into(),
456            },
457            mode: "apply",
458            target: "/tmp/t".into(),
459            profile: ProfileSnapshot {
460                technologies: vec!["rust".into()],
461                forge: Some("github".into()),
462                release: ReleaseIntent {
463                    mode: ReleaseMode::Automatic,
464                    driver: Some("rust".into()),
465                    style: Some(crate::landing::Style::Trunk),
466                    line_prefix: Some("release/".into()),
467                },
468            },
469            git: GitWorkflow {
470                trunk: "master".into(),
471                checkout_mode: CheckoutMode::MainWorktree,
472                integration: Integration::Local,
473                merge_form: crate::landing::MergeForm::Squash,
474            },
475            capabilities: CapabilityRequests {
476                nix_packaging: false,
477                reporting_policy: true,
478                scorecard: false,
479                code_scanning: Some(crate::landing::Provider::Semgrep),
480                workflow_guards: Some(false),
481            },
482            repo: "acme/widget".into(),
483            selection: vec![],
484            release_unavailable: None,
485            licence_refusal: None,
486            withheld: None,
487            files: vec![FileEntry {
488                path: "release-plz.toml".into(),
489                kind: "seeded",
490                action: "differs",
491            }],
492            replaced: vec![],
493            next: vec!["commit the config and the receipt".into()],
494        };
495        assert_eq!(
496            serde_json::to_string(&report).expect("a report serializes"),
497            r#"{"schema":"rk.adopt/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":"main-worktree","integration":"local","merge_form":"squash"},"capabilities":{"nix_packaging":false,"reporting_policy":true,"scorecard":false,"code_scanning":"semgrep","workflow_guards":false},"repo":"acme/widget","selection":[],"config":{"action":"added","changes":[],"content":"schema_version = 2\n"},"files":[{"path":"release-plz.toml","kind":"seeded","action":"differs"}],"replaced":[],"next":["commit the config and the receipt"]}"#
498        );
499    }
500}