Skip to main content

release_kit/commands/
adopt.rs

1//! `rk adopt`: a pre-record target becomes a recorded one.
2//!
3//! Adoption is a verification pass that happens to end in one write. 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. The one write is the manifest, last, 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};
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    /// Every destination, with its verification result.
51    files: Vec<FileEntry>,
52    /// What plausibly follows.
53    next: Vec<String>,
54}
55
56/// Verify the target against the rendered candidate and, on `--apply`,
57/// write the record and nothing else.
58///
59/// # Errors
60///
61/// Returns a refusal for a target already carrying a record, for any
62/// `rendered` mismatch or missing expected file — listing every one in
63/// one run — and [`RkError::Missing`] where detection resolves no
64/// technology, forge, or repository and no flag covers the gap.
65pub fn run(args: &AdoptArgs) -> Result<(), RkError> {
66    let out = Output::new(args.json);
67    if !args.target.is_dir() {
68        return Err(RkError::missing(
69            Diagnostic::new(
70                Reason::TargetNotFound,
71                format!("target {} is not a directory", args.target),
72            )
73            .expected("an existing repository to adopt"),
74        ));
75    }
76    if landing::manifest::load(&args.target)?.is_some() {
77        return Err(RkError::refusal(
78            Diagnostic::new(
79                Reason::StateDrift,
80                format!(
81                    "{} already carries {}; it needs no adoption",
82                    args.target,
83                    manifest::MANIFEST_PATH
84                ),
85            )
86            .expected("a target without a landing record")
87            .action(format!(
88                "rk upgrade --target {} takes it to this binary's payload",
89                args.target
90            ))
91            .target_state("unchanged"),
92        ));
93    }
94    let resolved = landing::resolve(&args.target, args.forge.as_deref(), args.repo.as_deref())?;
95    let repo = resolved.repo.ok_or_else(landing::repo_unresolved)?;
96    let tech = match args.tech.as_deref() {
97        Some(tech) => tech.to_owned(),
98        None => crate::detect::tech_of(args.target.as_std_path())
99            .ok_or_else(|| {
100                RkError::missing(
101                    Diagnostic::new(
102                        Reason::TargetNotFound,
103                        "no technology detected: the target has no version file",
104                    )
105                    .expected("a Cargo.toml, pyproject.toml, or VERSION file")
106                    .action("pass --tech <rust|python|bash>"),
107                )
108            })?
109            .to_owned(),
110    };
111    let entries = landing::projection(&tech, &resolved.forge, &repo)?;
112    let (files, records) = verify(args, &entries)?;
113
114    for file in &files {
115        out.result_line(match file.action {
116            "differs" => format!("differs {} (seeded, target-owned)", file.path),
117            action => format!("{action} {}", file.path),
118        });
119    }
120
121    if args.apply {
122        manifest::write(
123            &args.target,
124            &Manifest {
125                schema_version: manifest::SCHEMA_VERSION,
126                rk_version: env!("CARGO_PKG_VERSION").to_owned(),
127                payload_sha256: crate::commands::payload::report().payload_sha256,
128                origin: "adopt".to_owned(),
129                tech: tech.clone(),
130                forge: resolved.forge.clone(),
131                landed_at: manifest::now(),
132                parameters: Parameters { repo: repo.clone() },
133                files: records,
134                pins: registry::pins_for(&tech)
135                    .into_iter()
136                    .map(|pin| (pin.name, pin.version))
137                    .collect(),
138            },
139        )?;
140        out.result_line(format!("wrote {}", manifest::MANIFEST_PATH));
141    }
142
143    let next = if args.apply {
144        vec![
145            "commit the record".to_owned(),
146            format!("rk status --target {} reports this landing", args.target),
147        ]
148    } else {
149        vec![format!(
150            "rk adopt --target {} --apply writes the record and nothing else",
151            args.target
152        )]
153    };
154    out.next(&next);
155    out.emit(&Report {
156        schema: "rk.adopt/1",
157        mode: if args.apply { "apply" } else { "preview" },
158        target: args.target.to_string(),
159        tech,
160        forge: resolved.forge,
161        repo,
162        files,
163        next,
164    })
165}
166
167/// The verification pass: every destination checked against the rendered
168/// candidate, every failure collected before the one refusal, so an
169/// operator resolves everything and re-runs once.
170fn verify(
171    args: &AdoptArgs,
172    entries: &[landing::Entry],
173) -> Result<(Vec<FileEntry>, Vec<FileRecord>), RkError> {
174    let mut mismatches: Vec<String> = Vec::new();
175    let mut missing: Vec<String> = Vec::new();
176    let mut files = Vec::new();
177    let mut records = Vec::new();
178    for entry in entries {
179        let Some(bytes) = landing::read_destination(&args.target, entry)? else {
180            // A block-placed artifact reads as absent from a file that
181            // exists; the operator's remedy differs, so the label must.
182            let label = if args.target.join(&entry.destination).exists() {
183                format!("{} (carries no release-kit block)", entry.destination)
184            } else {
185                format!("{} (expected and missing)", entry.destination)
186            };
187            missing.push(label);
188            continue;
189        };
190        let action = match entry.kind {
191            Kind::Rendered | Kind::Seeded if bytes == entry.rendered => "matches",
192            Kind::Rendered => {
193                mismatches.push(entry.destination.clone());
194                "differs"
195            }
196            Kind::Seeded => "differs",
197            Kind::State => "state",
198        };
199        files.push(FileEntry {
200            path: entry.destination.clone(),
201            kind: entry.kind.as_str(),
202            action,
203        });
204        records.push(FileRecord {
205            destination: entry.destination.clone(),
206            kind: entry.kind,
207            sha256: Digest::of(&bytes),
208            baseline_sha256: match entry.kind {
209                Kind::State => None,
210                Kind::Rendered | Kind::Seeded => Some(Digest::of(&entry.baseline)),
211            },
212        });
213    }
214    if mismatches.is_empty() && missing.is_empty() {
215        return Ok((files, records));
216    }
217    let listed: Vec<String> = mismatches
218        .iter()
219        .map(|path| format!("{path} (differs from the rendered candidate)"))
220        .chain(missing.iter().cloned())
221        .collect();
222    Err(RkError::refusal(
223        Diagnostic::new(
224            Reason::StateDrift,
225            format!(
226                "this target is not adoptable as-is, and no record was written: {}",
227                listed.join(", ")
228            ),
229        )
230        .expected("every rendered destination matching this payload's candidate, byte for byte")
231        .action(
232            "restore each file to the candidate's bytes — rk snippet prints them — or take the difference deliberately through a fresh landing and a reviewed diff",
233        )
234        .target_state("unchanged"),
235    ))
236}
237
238#[cfg(test)]
239mod tests {
240    #![allow(clippy::expect_used)]
241
242    use super::{FileEntry, Report};
243
244    /// The complete `rk.adopt/1` shape, held by snapshot.
245    #[test]
246    fn the_adopt_report_schema_snapshot_holds() {
247        let report = Report {
248            schema: "rk.adopt/1",
249            mode: "apply",
250            target: "/tmp/t".into(),
251            tech: "rust".into(),
252            forge: "github".into(),
253            repo: "acme/widget".into(),
254            files: vec![FileEntry {
255                path: "release-plz.toml".into(),
256                kind: "seeded",
257                action: "differs",
258            }],
259            next: vec!["commit the record".into()],
260        };
261        assert_eq!(
262            serde_json::to_string(&report).expect("a report serializes"),
263            r#"{"schema":"rk.adopt/1","mode":"apply","target":"/tmp/t","tech":"rust","forge":"github","repo":"acme/widget","files":[{"path":"release-plz.toml","kind":"seeded","action":"differs"}],"next":["commit the record"]}"#
264        );
265    }
266}