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    crate::commands::init::report_moved_secrets(out, &held, &prepared)?;
211    let replaced = if let Some(lock) = &lock {
212        let landed = apply::land(&held, None, &prepared, apply::Origin::Adopt, lock)?;
213        for entry in &landed.replaced {
214            if let Some(saved) = &entry.saved_to {
215                out.result_line(format!("saved {} to {saved}", entry.path));
216            }
217        }
218        out.result_line(format!("wrote {}", manifest::MANIFEST_PATH));
219        landed.replaced
220    } else {
221        apply::previewed_replacements(&prepared)
222    };
223    drop(lock);
224    report(out, args, &params, &prepared, files, replaced)
225}
226
227/// The report's shape version.
228const SCHEMA: &str = "rk.adopt/11";
229
230/// Whether any answer flag was given, which `--stage` refuses beside it.
231const fn answered(args: &AdoptArgs) -> bool {
232    !args.profile.is_empty()
233        || args.nix_packaging
234        || args.reporting_policy
235        || args.scorecard
236        || args.workflow_guards
237        || args.code_scanning.is_some()
238}
239
240/// The report of a verified target, and of the receipt where one was
241/// written.
242fn report(
243    out: Output,
244    args: &AdoptArgs,
245    params: &landing::Params,
246    prepared: &Prepared,
247    files: Vec<FileEntry>,
248    replaced: Vec<Replaced>,
249) -> Result<(), RkError> {
250    let repo = params.repo().to_owned();
251    let mut next = if args.apply {
252        vec![
253            "commit the config and the receipt".to_owned(),
254            format!("rk status --target {} reports this landing", args.target),
255        ]
256    } else {
257        vec![format!(
258            "rk adopt{}{} --target {} --apply writes the config and the receipt inside .release-kit/",
259            params.canonical_flags(),
260            params.capability_flags(),
261            args.target
262        )]
263    };
264    if let Some(reason) = prepared.projection.release_unavailable() {
265        next.insert(
266            0,
267            format!("the apply refuses until the release automation resolves: {reason}"),
268        );
269    }
270    if let Some(reason) = prepared.projection.licence_refusal.as_deref() {
271        next.insert(
272            0,
273            format!("the apply refuses until the licence condition is answered: {reason}"),
274        );
275    }
276    out.result_line(format!(
277        "{} {}\n{}",
278        prepared.config.action,
279        crate::config::CONFIG_PATH,
280        prepared.config.content
281    ));
282    out.next(&next);
283    out.emit(&Report {
284        schema: SCHEMA,
285        config: prepared.config.clone(),
286        mode: if args.apply { "apply" } else { "preview" },
287        target: args.target.to_string(),
288        profile: params.profile().clone(),
289        git: params.git().clone(),
290        capabilities: params.capabilities().clone(),
291        repo,
292        selection: prepared
293            .projection
294            .capabilities
295            .iter()
296            .map(|selection| CapabilityNote::of(selection, &prepared.projection))
297            .collect(),
298        release_unavailable: prepared.projection.release_unavailable().map(str::to_owned),
299        licence_refusal: prepared.projection.licence_refusal.clone(),
300        withheld: {
301            let withheld: Vec<landing::Withheld> = prepared
302                .projection
303                .omissions
304                .iter()
305                .map(|omission| landing::Withheld {
306                    path: omission.destination.clone(),
307                    reason: omission.reason.clone(),
308                })
309                .collect();
310            (!withheld.is_empty()).then_some(withheld)
311        },
312        files,
313        replaced,
314        next,
315    })
316}
317
318/// The verification pass: every candidate checked against the disk,
319/// every failure collected before the one refusal, so an operator
320/// resolves everything and re-runs once. A rendered mismatch or a missing
321/// expected file `replace` names is the operator's answered conflict: it
322/// joins the replacements instead of the refusal.
323///
324/// SATISFIES landing:an-adoption-writes-the-record-and-nothing-else
325#[allow(
326    clippy::too_many_lines,
327    reason = "one pass judges every candidate, the named conflicts, and the one refusal"
328)]
329fn verify(
330    held: &apply::Held,
331    checkout_mode: CheckoutMode,
332    prepared: &mut Prepared,
333    replace: &[String],
334) -> Result<Vec<FileEntry>, RkError> {
335    let named = |destination: &str| replace.iter().any(|path| path == destination);
336    let mut answered: Vec<String> = Vec::new();
337    let target = held.display();
338    let mut mismatches: Vec<String> = Vec::new();
339    let mut missing: Vec<String> = Vec::new();
340    let mut files = Vec::new();
341    // An ill-formed marked document lists beside the mismatches rather
342    // than refusing alone, so one run still names everything unadoptable.
343    let defects: Vec<String> = prepared
344        .projection
345        .collisions
346        .iter()
347        .map(|collision| collision.reason.clone())
348        .collect();
349    for candidate in &prepared.projection.candidates {
350        let judged = apply::judge_adoption(held, candidate)?;
351        let answers = judged.replaceable(candidate.kind) && named(&candidate.destination);
352        let action = match judged {
353            Adoption::Irregular => {
354                mismatches.push(format!("{} (is not a regular file)", candidate.destination));
355                continue;
356            }
357            Adoption::Missing { exists } => {
358                if answers {
359                    answered.push(candidate.destination.clone());
360                    files.push(FileEntry {
361                        path: candidate.destination.clone(),
362                        kind: candidate.kind.as_str(),
363                        action: "replaced",
364                    });
365                    continue;
366                }
367                // A block-placed artifact reads as absent from a file that
368                // exists; the operator's remedy differs, so the label must.
369                missing.push(if exists {
370                    format!("{} (carries no release-kit block)", candidate.destination)
371                } else {
372                    format!("{} (expected and missing)", candidate.destination)
373                });
374                continue;
375            }
376            Adoption::Matches => "matches",
377            Adoption::Differs if answers => {
378                answered.push(candidate.destination.clone());
379                "replaced"
380            }
381            Adoption::Differs => {
382                mismatches.push(format!(
383                    "{} (differs from the rendered candidate)",
384                    candidate.destination
385                ));
386                "differs"
387            }
388            Adoption::Tuned => "differs",
389            Adoption::State => "state",
390        };
391        files.push(FileEntry {
392            path: candidate.destination.clone(),
393            kind: candidate.kind.as_str(),
394            action,
395        });
396    }
397    if let Some(stray) = replace.iter().find(|path| !answered.contains(path)) {
398        return Err(RkError::Usage(format!(
399            "--replace {stray} names no conflict this adoption has; a conflict is a rendered destination that differs from the candidate or is missing"
400        )));
401    }
402    if mismatches.is_empty() && missing.is_empty() && defects.is_empty() {
403        prepared.replacements = answered;
404        return Ok(files);
405    }
406    let listed: Vec<String> = mismatches
407        .iter()
408        .cloned()
409        .chain(missing.iter().cloned())
410        .chain(defects.iter().cloned())
411        .collect();
412    Err(RkError::refusal(
413        Diagnostic::new(
414            Reason::StateDrift,
415            format!(
416                "this target is not adoptable as-is, and no receipt was written: {}",
417                listed.join(", ")
418            ),
419        )
420        .expected(format!(
421            "every rendered destination matching the {} candidate, byte for byte",
422            checkout_mode.as_str()
423        ))
424        .action(format!(
425            "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{}",
426            target,
427            // A policy the target wrote its own contact into is the one
428            // mismatch a committed answer resolves rather than an edit:
429            // naming the keys turns a dead end into the next step.
430            if mismatches.iter().any(|path| path.starts_with("SECURITY.md ")) {
431                ". 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"
432            } else {
433                ""
434            }
435        ))
436        .target_state("unchanged"),
437    ))
438}
439
440#[cfg(test)]
441mod tests {
442    use super::{FileEntry, Report};
443    use crate::landing::{CheckoutMode, Integration};
444    use crate::profile::{
445        CapabilityRequests, GitWorkflow, ProfileSnapshot, ReleaseIntent, ReleaseMode,
446    };
447
448    /// The complete `rk.adopt/11` shape, held by snapshot.
449    #[test]
450    fn the_adopt_report_schema_snapshot_holds() {
451        let report = Report {
452            schema: super::SCHEMA,
453            config: crate::config::Plan {
454                action: "added",
455                changes: vec![],
456                content: "schema_version = 2\n".into(),
457            },
458            mode: "apply",
459            target: "/tmp/t".into(),
460            profile: ProfileSnapshot {
461                technologies: vec!["rust".into()],
462                forge: Some("github".into()),
463                release: ReleaseIntent {
464                    mode: ReleaseMode::Automatic,
465                    driver: Some("rust".into()),
466                    style: Some(crate::landing::Style::Trunk),
467                    line_prefix: Some("release/".into()),
468                    registry: None,
469                    build: None,
470                },
471            },
472            git: GitWorkflow {
473                trunk: "master".into(),
474                checkout_mode: CheckoutMode::MainWorktree,
475                integration: Integration::Local,
476                merge_form: crate::landing::MergeForm::Squash,
477            },
478            capabilities: CapabilityRequests {
479                nix_packaging: false,
480                reporting_policy: true,
481                scorecard: false,
482                code_scanning: Some(crate::landing::Provider::Semgrep),
483                workflow_guards: Some(false),
484            },
485            repo: "acme/widget".into(),
486            selection: vec![],
487            release_unavailable: None,
488            licence_refusal: None,
489            withheld: None,
490            files: vec![FileEntry {
491                path: "release-plz.toml".into(),
492                kind: "seeded",
493                action: "differs",
494            }],
495            replaced: vec![],
496            next: vec!["commit the config and the receipt".into()],
497        };
498        assert_eq!(
499            serde_json::to_string(&report).expect("a report serializes"),
500            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"]}"#
501        );
502    }
503}