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