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