Skip to main content

release_kit/commands/
adopt.rs

1//! `rk adopt`: a pre-record target becomes a recorded one.
2//!
3//! Adoption verifies the payload before writing configuration and its record. The
4//! candidate payload is rendered first, exactly as `rk init` would
5//! produce it; every `rendered` destination must match it byte for byte,
6//! and one mismatch refuses the whole adoption listing every mismatch in
7//! one run. Blessing whatever is on disk would launder arbitrary drift
8//! into release-kit ownership, so nothing here ever takes the disk as the
9//! baseline — and no target file is ever changed: not a byte, not a mode,
10//! not a sentinel. Configuration writes before the manifest, after every check
11//! has passed.
12
13use serde::Serialize;
14
15use crate::cli::adopt::AdoptArgs;
16use crate::diagnostic::{Diagnostic, Reason};
17use crate::digest::Digest;
18use crate::error::RkError;
19use crate::landing::manifest::{self, FileRecord, Manifest, Parameters, Style, Workflow};
20use crate::landing::{self, Kind};
21use crate::output::Output;
22use crate::registry;
23
24/// One verified destination.
25#[derive(Debug, Serialize)]
26struct FileEntry {
27    /// The destination, relative to the target.
28    path: String,
29    /// The declared ownership kind.
30    kind: &'static str,
31    /// `matches`, `differs` for a seeded file, or `state`.
32    action: &'static str,
33}
34
35/// The machine form of an adoption report.
36#[derive(Debug, Serialize)]
37struct Report {
38    /// The shape version of this document.
39    schema: &'static str,
40    /// `preview` or `apply`.
41    mode: &'static str,
42    /// The target directory.
43    target: String,
44    /// The technology whose payload was verified.
45    tech: String,
46    /// The forge whose payload was verified.
47    forge: String,
48    /// The parameter the candidate was rendered under.
49    repo: String,
50    /// The working-copy mode the candidate was rendered under and the
51    /// record carries.
52    workflow: &'static str,
53    style: &'static str,
54    /// Whether the record carries the Nix capability.
55    nix: bool,
56    /// The Nix destinations excluded from the candidate, each with why;
57    /// absent where nothing was withheld.
58    #[serde(skip_serializing_if = "Option::is_none")]
59    withheld: Option<Vec<landing::Withheld>>,
60    config: crate::config::Plan,
61    /// Every destination, with its verification result.
62    files: Vec<FileEntry>,
63    /// What plausibly follows.
64    next: Vec<String>,
65}
66
67/// Verify the target against the rendered candidate and, on `--apply`,
68/// write the config and record inside `.release-kit/`.
69///
70/// # Errors
71///
72/// Returns a refusal for a target already carrying a record, for any
73/// `rendered` mismatch or missing expected file — listing every one in
74/// one run — and [`RkError::Missing`] where detection resolves no
75/// technology, forge, or repository and no flag covers the gap.
76#[allow(
77    clippy::too_many_lines,
78    reason = "one adopt run is one linear sequence of checks against one target, and cutting it would separate a refusal from the order it is reported in"
79)]
80pub fn run(args: &AdoptArgs) -> Result<(), RkError> {
81    let out = Output::new(args.json);
82    if !args.target.is_dir() {
83        return Err(RkError::missing(
84            Diagnostic::new(
85                Reason::TargetNotFound,
86                format!("target {} is not a directory", args.target),
87            )
88            .expected("an existing repository to adopt"),
89        ));
90    }
91    if landing::manifest::load(&args.target)?.is_some() {
92        return Err(RkError::refusal(
93            Diagnostic::new(
94                Reason::StateDrift,
95                format!(
96                    "{} already carries {}; it needs no adoption",
97                    args.target,
98                    manifest::MANIFEST_PATH
99                ),
100            )
101            .expected("a target without a landing record")
102            .action(format!(
103                "rk upgrade --target {} takes it to this binary's payload",
104                args.target
105            ))
106            .target_state("unchanged"),
107        ));
108    }
109    let config = crate::config::load(args.target.as_std_path())?;
110    let params = landing::Params::resolve(
111        &args.target,
112        &landing::Inputs {
113            tech: args.tech.as_deref(),
114            forge: args.forge.as_deref(),
115            repo: args.repo.as_deref(),
116            workflow: args.workflow.as_deref().map(Workflow::parse).transpose()?,
117            style: args.style.as_deref().map(Style::parse).transpose()?,
118            nix: args.nix.then_some(true),
119        },
120        config.as_ref(),
121        None,
122        landing::Purpose::Adopt,
123    )?;
124    let config =
125        crate::config::Plan::new(args.target.as_std_path(), &params, config.as_ref(), None)?;
126    let tech = params.tech().to_owned();
127    let repo = params.repo().to_owned();
128    let workflow = params.workflow();
129    let style = params
130        .style()
131        .ok_or_else(|| RkError::Usage("landing style is unresolved".into()))?;
132    let mut entries = landing::projection(&params)?;
133    let withheld = landing::withhold_nix(&args.target, params.nix(), None, &mut entries)?;
134    let (files, records) = verify(args, workflow, &entries)?;
135
136    for file in &files {
137        out.result_line(match file.action {
138            "differs" => format!("differs {} (seeded, target-owned)", file.path),
139            action => format!("{action} {}", file.path),
140        });
141    }
142    for entry in &withheld {
143        out.result_line(format!("withheld {}: {}", entry.path, entry.reason));
144    }
145
146    if args.apply {
147        config.apply(args.target.as_std_path())?;
148        manifest::write(
149            &args.target,
150            &Manifest {
151                schema_version: manifest::SCHEMA_VERSION,
152                rk_version: env!("CARGO_PKG_VERSION").to_owned(),
153                payload_sha256: crate::commands::payload::report().payload_sha256,
154                origin: "adopt".to_owned(),
155                tech: tech.clone(),
156                forge: params.forge().to_owned(),
157                landed_at: manifest::now(),
158                parameters: Parameters {
159                    repo: repo.clone(),
160                    workflow,
161                    style: Some(style),
162                    nix: params.nix(),
163                    trunk: params.trunk().to_owned(),
164                    line_prefix: params.line_prefix().to_owned(),
165                    security_contact: params.security_contact().to_owned(),
166                    security_response: params.security_response().to_owned(),
167                },
168                files: records,
169                pins: registry::pins_for(&tech)
170                    .into_iter()
171                    .map(|pin| (pin.name, pin.version))
172                    .collect(),
173            },
174        )?;
175        out.result_line(format!("wrote {}", manifest::MANIFEST_PATH));
176    }
177
178    let next = if args.apply {
179        vec![
180            "commit the config and the record".to_owned(),
181            format!("rk status --target {} reports this landing", args.target),
182        ]
183    } else {
184        vec![format!(
185            "rk adopt --tech {tech} --forge {} --repo {repo} --workflow {} --style {}{} --target {} --apply writes the config and the record inside .release-kit/",
186            params.forge().to_owned(),
187            workflow.as_str(),
188            style.as_str(),
189            if params.nix() { " --nix" } else { "" },
190            args.target
191        )]
192    };
193    out.result_line(format!(
194        "{} {}\n{}",
195        config.action,
196        crate::config::CONFIG_PATH,
197        config.content
198    ));
199    out.next(&next);
200    out.emit(&Report {
201        schema: "rk.adopt/5",
202        config,
203        mode: if args.apply { "apply" } else { "preview" },
204        target: args.target.to_string(),
205        tech,
206        forge: params.forge().to_owned(),
207        repo,
208        workflow: workflow.as_str(),
209        style: style.as_str(),
210        nix: params.nix(),
211        withheld: (!withheld.is_empty()).then_some(withheld),
212        files,
213        next,
214    })
215}
216
217/// The verification pass: every destination checked against the rendered
218/// candidate, every failure collected before the one refusal, so an
219/// operator resolves everything and re-runs once.
220fn verify(
221    args: &AdoptArgs,
222    workflow: Workflow,
223    entries: &[landing::Entry],
224) -> Result<(Vec<FileEntry>, Vec<FileRecord>), RkError> {
225    let mut mismatches: Vec<String> = Vec::new();
226    let mut missing: Vec<String> = Vec::new();
227    let mut files = Vec::new();
228    let mut records = Vec::new();
229    // An ill-formed hook file lists beside the mismatches rather than
230    // refusing alone, so one run still names everything unadoptable.
231    let mut defects: Vec<String> = Vec::new();
232    if let Some(defect) = landing::hooks_file_defect(&args.target)? {
233        defects.push(defect);
234    }
235    for entry in entries {
236        let Some(bytes) = landing::read_destination(&args.target, entry)? else {
237            // A block-placed artifact reads as absent from a file that
238            // exists; the operator's remedy differs, so the label must.
239            let label = if args.target.join(&entry.destination).exists() {
240                format!("{} (carries no release-kit block)", entry.destination)
241            } else {
242                format!("{} (expected and missing)", entry.destination)
243            };
244            missing.push(label);
245            continue;
246        };
247        let action = match entry.kind {
248            Kind::Rendered | Kind::Seeded if bytes == entry.rendered => "matches",
249            Kind::Rendered => {
250                mismatches.push(entry.destination.clone());
251                "differs"
252            }
253            Kind::Seeded => "differs",
254            Kind::State => "state",
255        };
256        files.push(FileEntry {
257            path: entry.destination.clone(),
258            kind: entry.kind.as_str(),
259            action,
260        });
261        records.push(FileRecord {
262            destination: entry.destination.clone(),
263            kind: entry.kind,
264            sha256: Digest::of(&bytes),
265            baseline_sha256: match entry.kind {
266                Kind::State => None,
267                Kind::Rendered | Kind::Seeded => Some(Digest::of(&entry.baseline)),
268            },
269        });
270    }
271    if mismatches.is_empty() && missing.is_empty() && defects.is_empty() {
272        return Ok((files, records));
273    }
274    let listed: Vec<String> = mismatches
275        .iter()
276        .map(|path| format!("{path} (differs from the rendered candidate)"))
277        .chain(missing.iter().cloned())
278        .chain(defects.iter().cloned())
279        .collect();
280    Err(RkError::refusal(
281        Diagnostic::new(
282            Reason::StateDrift,
283            format!(
284                "this target is not adoptable as-is, and no record was written: {}",
285                listed.join(", ")
286            ),
287        )
288        .expected(format!(
289            "every rendered destination matching the {} candidate, byte for byte",
290            workflow.as_str()
291        ))
292        .action(format!(
293            "align first: rk adopt without --apply lists every differing destination; bring each to the selected candidate's bytes — rk snippet and rk payload print them — then re-run, or select the other candidate with --workflow or --style{}",
294            // A policy the target wrote its own contact into is the one
295            // mismatch a committed answer resolves rather than an edit:
296            // naming the keys turns a dead end into the next step.
297            if mismatches.iter().any(|path| path == "SECURITY.md") {
298                ". 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"
299            } else {
300                ""
301            }
302        ))
303        .target_state("unchanged"),
304    ))
305}
306
307#[cfg(test)]
308mod tests {
309    use super::{FileEntry, Report};
310
311    /// The complete `rk.adopt/5` shape, held by snapshot.
312    #[test]
313    fn the_adopt_report_schema_snapshot_holds() {
314        let report = Report {
315            schema: "rk.adopt/5",
316            config: crate::config::Plan {
317                action: "added",
318                changes: vec![],
319                content: "schema_version = 1\n".into(),
320            },
321            mode: "apply",
322            target: "/tmp/t".into(),
323            tech: "rust".into(),
324            forge: "github".into(),
325            repo: "acme/widget".into(),
326            workflow: "branches",
327            style: "trunk",
328            nix: false,
329            withheld: None,
330            files: vec![FileEntry {
331                path: "release-plz.toml".into(),
332                kind: "seeded",
333                action: "differs",
334            }],
335            next: vec!["commit the config and the record".into()],
336        };
337        assert_eq!(
338            serde_json::to_string(&report).expect("a report serializes"),
339            r#"{"schema":"rk.adopt/5","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 record"]}"#
340        );
341    }
342}