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, 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    /// Every destination, with its verification result.
61    files: Vec<FileEntry>,
62    /// What plausibly follows.
63    next: Vec<String>,
64}
65
66/// Verify the target against the rendered candidate and, on `--apply`,
67/// write the record and nothing else.
68///
69/// # Errors
70///
71/// Returns a refusal for a target already carrying a record, for any
72/// `rendered` mismatch or missing expected file — listing every one in
73/// one run — and [`RkError::Missing`] where detection resolves no
74/// technology, forge, or repository and no flag covers the gap.
75#[allow(clippy::too_many_lines)]
76pub fn run(args: &AdoptArgs) -> Result<(), RkError> {
77    let out = Output::new(args.json);
78    if !args.target.is_dir() {
79        return Err(RkError::missing(
80            Diagnostic::new(
81                Reason::TargetNotFound,
82                format!("target {} is not a directory", args.target),
83            )
84            .expected("an existing repository to adopt"),
85        ));
86    }
87    if landing::manifest::load(&args.target)?.is_some() {
88        return Err(RkError::refusal(
89            Diagnostic::new(
90                Reason::StateDrift,
91                format!(
92                    "{} already carries {}; it needs no adoption",
93                    args.target,
94                    manifest::MANIFEST_PATH
95                ),
96            )
97            .expected("a target without a landing record")
98            .action(format!(
99                "rk upgrade --target {} takes it to this binary's payload",
100                args.target
101            ))
102            .target_state("unchanged"),
103        ));
104    }
105    let resolved = landing::resolve(&args.target, args.forge.as_deref(), args.repo.as_deref())?;
106    let repo = resolved.repo.ok_or_else(landing::repo_unresolved)?;
107    let tech = resolved_tech(args)?;
108    let workflow = Workflow::parse(&args.workflow)?;
109    // The style is required rather than defaulted: it changes the release
110    // workflow's bytes, and an adoption verifies bytes against exactly one
111    // rendered candidate, so neither value is a safe guess.
112    let style = Style::parse(args.style.as_deref().ok_or_else(|| {
113        RkError::Usage(
114            "an adoption verifies against one rendered candidate; pass --style <trunk|lines>, the release style this target runs".into(),
115        )
116    })?)?;
117    let mut entries = landing::projection(
118        &tech,
119        &resolved.forge,
120        &repo,
121        workflow,
122        Some(style),
123        args.nix,
124    )?;
125    // A target whose flake pair is its own is verified without the pair
126    // and the workflow, exactly as a landing would have withheld them, so
127    // the record an adoption writes is one a later upgrade reproduces.
128    let withheld = landing::withhold_nix(&args.target, args.nix, None, &mut entries)?;
129    let (files, records) = verify(args, workflow, &entries)?;
130
131    for file in &files {
132        out.result_line(match file.action {
133            "differs" => format!("differs {} (seeded, target-owned)", file.path),
134            action => format!("{action} {}", file.path),
135        });
136    }
137    for entry in &withheld {
138        out.result_line(format!("withheld {}: {}", entry.path, entry.reason));
139    }
140
141    if args.apply {
142        manifest::write(
143            &args.target,
144            &Manifest {
145                schema_version: manifest::SCHEMA_VERSION,
146                rk_version: env!("CARGO_PKG_VERSION").to_owned(),
147                payload_sha256: crate::commands::payload::report().payload_sha256,
148                origin: "adopt".to_owned(),
149                tech: tech.clone(),
150                forge: resolved.forge.clone(),
151                landed_at: manifest::now(),
152                parameters: Parameters {
153                    repo: repo.clone(),
154                    workflow,
155                    style: Some(style),
156                    nix: args.nix,
157                },
158                files: records,
159                pins: registry::pins_for(&tech)
160                    .into_iter()
161                    .map(|pin| (pin.name, pin.version))
162                    .collect(),
163            },
164        )?;
165        out.result_line(format!("wrote {}", manifest::MANIFEST_PATH));
166    }
167
168    let next = if args.apply {
169        vec![
170            "commit the record".to_owned(),
171            format!("rk status --target {} reports this landing", args.target),
172        ]
173    } else {
174        vec![format!(
175            "rk adopt --tech {tech} --forge {} --repo {repo} --workflow {} --style {}{} --target {} --apply writes the record and nothing else",
176            resolved.forge,
177            workflow.as_str(),
178            style.as_str(),
179            if args.nix { " --nix" } else { "" },
180            args.target
181        )]
182    };
183    out.next(&next);
184    out.emit(&Report {
185        schema: "rk.adopt/4",
186        mode: if args.apply { "apply" } else { "preview" },
187        target: args.target.to_string(),
188        tech,
189        forge: resolved.forge,
190        repo,
191        workflow: workflow.as_str(),
192        style: style.as_str(),
193        nix: args.nix,
194        withheld: (!withheld.is_empty()).then_some(withheld),
195        files,
196        next,
197    })
198}
199
200/// The verification pass: every destination checked against the rendered
201/// candidate, every failure collected before the one refusal, so an
202/// operator resolves everything and re-runs once.
203fn verify(
204    args: &AdoptArgs,
205    workflow: Workflow,
206    entries: &[landing::Entry],
207) -> Result<(Vec<FileEntry>, Vec<FileRecord>), RkError> {
208    let mut mismatches: Vec<String> = Vec::new();
209    let mut missing: Vec<String> = Vec::new();
210    let mut files = Vec::new();
211    let mut records = Vec::new();
212    // An ill-formed hook file lists beside the mismatches rather than
213    // refusing alone, so one run still names everything unadoptable.
214    let mut defects: Vec<String> = Vec::new();
215    if let Some(defect) = landing::hooks_file_defect(&args.target)? {
216        defects.push(defect);
217    }
218    for entry in entries {
219        let Some(bytes) = landing::read_destination(&args.target, entry)? else {
220            // A block-placed artifact reads as absent from a file that
221            // exists; the operator's remedy differs, so the label must.
222            let label = if args.target.join(&entry.destination).exists() {
223                format!("{} (carries no release-kit block)", entry.destination)
224            } else {
225                format!("{} (expected and missing)", entry.destination)
226            };
227            missing.push(label);
228            continue;
229        };
230        let action = match entry.kind {
231            Kind::Rendered | Kind::Seeded if bytes == entry.rendered => "matches",
232            Kind::Rendered => {
233                mismatches.push(entry.destination.clone());
234                "differs"
235            }
236            Kind::Seeded => "differs",
237            Kind::State => "state",
238        };
239        files.push(FileEntry {
240            path: entry.destination.clone(),
241            kind: entry.kind.as_str(),
242            action,
243        });
244        records.push(FileRecord {
245            destination: entry.destination.clone(),
246            kind: entry.kind,
247            sha256: Digest::of(&bytes),
248            baseline_sha256: match entry.kind {
249                Kind::State => None,
250                Kind::Rendered | Kind::Seeded => Some(Digest::of(&entry.baseline)),
251            },
252        });
253    }
254    if mismatches.is_empty() && missing.is_empty() && defects.is_empty() {
255        return Ok((files, records));
256    }
257    let listed: Vec<String> = mismatches
258        .iter()
259        .map(|path| format!("{path} (differs from the rendered candidate)"))
260        .chain(missing.iter().cloned())
261        .chain(defects.iter().cloned())
262        .collect();
263    Err(RkError::refusal(
264        Diagnostic::new(
265            Reason::StateDrift,
266            format!(
267                "this target is not adoptable as-is, and no record was written: {}",
268                listed.join(", ")
269            ),
270        )
271        .expected(format!(
272            "every rendered destination matching the {} candidate, byte for byte",
273            workflow.as_str()
274        ))
275        .action(
276            "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",
277        )
278        .target_state("unchanged"),
279    ))
280}
281
282/// The technology whose payload the target runs: the flag, or detection
283/// from the version file.
284fn resolved_tech(args: &AdoptArgs) -> Result<String, RkError> {
285    args.tech.as_deref().map_or_else(
286        || {
287            crate::detect::tech_of(args.target.as_std_path())
288                .map(str::to_owned)
289                .ok_or_else(|| {
290                    RkError::missing(
291                        Diagnostic::new(
292                            Reason::TargetNotFound,
293                            "no technology detected: the target has no version file",
294                        )
295                        .expected("a Cargo.toml, pyproject.toml, or VERSION file")
296                        .action("pass --tech <rust|python|bash>"),
297                    )
298                })
299        },
300        |tech| Ok(tech.to_owned()),
301    )
302}
303
304#[cfg(test)]
305mod tests {
306    #![allow(clippy::expect_used)]
307
308    use super::{FileEntry, Report};
309
310    /// The complete `rk.adopt/4` shape, held by snapshot.
311    #[test]
312    fn the_adopt_report_schema_snapshot_holds() {
313        let report = Report {
314            schema: "rk.adopt/4",
315            mode: "apply",
316            target: "/tmp/t".into(),
317            tech: "rust".into(),
318            forge: "github".into(),
319            repo: "acme/widget".into(),
320            workflow: "branches",
321            style: "trunk",
322            nix: false,
323            withheld: None,
324            files: vec![FileEntry {
325                path: "release-plz.toml".into(),
326                kind: "seeded",
327                action: "differs",
328            }],
329            next: vec!["commit the record".into()],
330        };
331        assert_eq!(
332            serde_json::to_string(&report).expect("a report serializes"),
333            r#"{"schema":"rk.adopt/4","mode":"apply","target":"/tmp/t","tech":"rust","forge":"github","repo":"acme/widget","workflow":"branches","style":"trunk","nix":false,"files":[{"path":"release-plz.toml","kind":"seeded","action":"differs"}],"next":["commit the record"]}"#
334        );
335    }
336}