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