Skip to main content

release_kit/commands/
adopt.rs

1//! `rk adopt`: a pre-record target becomes a recorded one.
2//!
3//! A front over the engine: the plan is computed with the adopt intent,
4//! which verifies every destination and writes none, and on `--apply`
5//! the engine writes the configuration and the record, last, through
6//! one staged transaction. The candidate payload is rendered first,
7//! exactly as `rk init` would produce it; every `rendered` destination
8//! must match it byte for byte, and one mismatch refuses the whole
9//! adoption listing every mismatch in one run. Blessing whatever is on
10//! disk would launder arbitrary drift into release-kit ownership, so
11//! nothing here ever takes the disk as the baseline — and no target file
12//! is ever changed: not a byte, not a mode, not a sentinel.
13
14use serde::Serialize;
15
16use crate::cli::adopt::AdoptArgs;
17use crate::commands::reconcile::{self, FrontApplied, Trace};
18use crate::diagnostic::{Diagnostic, Reason};
19use crate::error::RkError;
20use crate::landing::manifest::{self, Style, Workflow};
21use crate::landing::{self, Kind};
22use crate::output::Output;
23use crate::plan::gather::Flags;
24use crate::plan::{Disposition, Intent, PlanRequest, Planned};
25use crate::release::EmbeddedReleaseSource;
26
27/// One verified destination.
28#[derive(Debug, Serialize)]
29struct FileEntry {
30    /// The destination, relative to the target.
31    path: String,
32    /// The declared ownership kind.
33    kind: &'static str,
34    /// `matches`, `differs` for a seeded file, or `state`.
35    action: &'static str,
36}
37
38/// The machine form of an adoption report.
39#[derive(Debug, Serialize)]
40struct Report {
41    /// The shape version of this document.
42    schema: &'static str,
43    /// `preview` or `apply`.
44    mode: &'static str,
45    /// The target directory.
46    target: String,
47    /// The technology whose payload was verified.
48    tech: String,
49    /// The forge whose payload was verified.
50    forge: String,
51    /// The parameter the candidate was rendered under.
52    repo: String,
53    /// The working-copy mode the candidate was rendered under and the
54    /// record carries.
55    workflow: &'static str,
56    style: &'static str,
57    /// Whether the record carries the Nix capability.
58    nix: bool,
59    /// The Nix destinations excluded from the candidate, each with why;
60    /// absent where nothing was withheld.
61    #[serde(skip_serializing_if = "Option::is_none")]
62    withheld: Option<Vec<landing::Withheld>>,
63    config: crate::config::Plan,
64    /// Every destination, with its verification result.
65    files: Vec<FileEntry>,
66    /// The plan the apply executed; absent in a preview.
67    #[serde(skip_serializing_if = "Option::is_none")]
68    plan: Option<Trace>,
69    /// What plausibly follows.
70    next: Vec<String>,
71}
72
73/// Verify the target against the rendered candidate and, on `--apply`,
74/// write the config and record inside `.release-kit/`.
75///
76/// # Errors
77///
78/// Returns a refusal for a target already carrying a record, for any
79/// `rendered` mismatch or missing expected file — listing every one in
80/// one run — and [`RkError::Missing`] where detection resolves no
81/// technology, forge, or repository and no flag covers the gap.
82#[allow(
83    clippy::too_many_lines,
84    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"
85)]
86pub fn run(args: &AdoptArgs) -> Result<(), RkError> {
87    let out = Output::new(args.json);
88    if !args.target.is_dir() {
89        return Err(RkError::missing(
90            Diagnostic::new(
91                Reason::TargetNotFound,
92                format!("target {} is not a directory", args.target),
93            )
94            .expected("an existing repository to adopt"),
95        ));
96    }
97    if landing::manifest::load(&args.target)?.is_some() {
98        return Err(RkError::refusal(
99            Diagnostic::new(
100                Reason::StateDrift,
101                format!(
102                    "{} already carries {}; it needs no adoption",
103                    args.target,
104                    manifest::MANIFEST_PATH
105                ),
106            )
107            .expected("a target without a landing record")
108            .action(format!(
109                "rk upgrade --target {} takes it to this binary's payload",
110                args.target
111            ))
112            .target_state("unchanged"),
113        ));
114    }
115    let config = crate::config::load(args.target.as_std_path())?;
116    let source = EmbeddedReleaseSource;
117    let params = landing::Params::resolve(
118        &source,
119        &args.target,
120        &landing::Inputs {
121            tech: args.tech.as_deref(),
122            forge: args.forge.as_deref(),
123            repo: args.repo.as_deref(),
124            workflow: args.workflow.as_deref().map(Workflow::parse).transpose()?,
125            style: args.style.as_deref().map(Style::parse).transpose()?,
126            nix: args.nix.then_some(true),
127        },
128        config.as_ref(),
129        None,
130        landing::Purpose::Adopt,
131    )?;
132    let tech = params.tech().to_owned();
133    let repo = params.repo().to_owned();
134    let workflow = params.workflow();
135    let style = params
136        .style()
137        .ok_or_else(|| RkError::Usage("landing style is unresolved".into()))?;
138    let request = PlanRequest {
139        target: args.target.clone(),
140        intent: Intent::Adopt,
141        selector: "embedded".into(),
142        fetch: false,
143        observe_forge: false,
144        flags: Flags {
145            tech: Some(tech.clone()),
146            forge: Some(params.forge().to_owned()),
147            repo: Some(repo.clone()),
148            workflow: Some(workflow.as_str().to_owned()),
149            style: Some(style.as_str().to_owned()),
150            nix: Some(params.nix()),
151        },
152        decisions: std::collections::BTreeMap::new(),
153    }
154    .canonicalized()?;
155    let planned = reconcile::compute(&request, &manifest::now())?;
156    let config = planned
157        .config
158        .clone()
159        .ok_or_else(|| RkError::Usage("landing parameters are unresolved".into()))?;
160    let files = verify(args, workflow, &planned)?;
161
162    for file in &files {
163        out.result_line(match file.action {
164            "differs" => format!("differs {} (seeded, target-owned)", file.path),
165            action => format!("{action} {}", file.path),
166        });
167    }
168    for entry in &planned.withheld {
169        out.result_line(format!("withheld {}: {}", entry.path, entry.reason));
170    }
171
172    let applied = if args.apply {
173        let applied = reconcile::apply_in_process(&planned, &request, "adopt")?;
174        out.result_line(format!("wrote {}", manifest::MANIFEST_PATH));
175        out.result_line(applied.line());
176        Some(applied)
177    } else {
178        None
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/6",
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: (!planned.withheld.is_empty()).then(|| planned.withheld.clone()),
215        files,
216        plan: applied.as_ref().map(FrontApplied::trace),
217        next,
218    })?;
219    applied
220        .and_then(|applied| applied.applied.failure())
221        .map_or(Ok(()), Err)
222}
223
224/// The verification pass, read off the plan: every destination checked
225/// against the rendered candidate, every failure collected before the one
226/// refusal, so an operator resolves everything and re-runs once.
227fn verify(
228    args: &AdoptArgs,
229    workflow: Workflow,
230    planned: &Planned,
231) -> Result<Vec<FileEntry>, RkError> {
232    let mut mismatches: Vec<String> = Vec::new();
233    let mut missing: Vec<String> = Vec::new();
234    let mut files = Vec::new();
235    // An ill-formed hook file lists beside the mismatches rather than
236    // refusing alone, so one run still names everything unadoptable.
237    let defects: Vec<String> = planned
238        .plan
239        .preconditions
240        .iter()
241        .filter(|p| p.id == "hooks-file-spliceable")
242        .filter_map(|p| match &p.evaluation {
243            crate::plan::Evaluation::Unsatisfied { reason } => Some(reason.clone()),
244            _ => None,
245        })
246        .collect();
247    for outcome in &planned.outcomes {
248        if outcome.disposition == Disposition::Write {
249            // A block-placed artifact reads as absent from a file that
250            // exists; the operator's remedy differs, so the label must.
251            let label = if args.target.join(&outcome.path).exists() {
252                format!("{} (carries no release-kit block)", outcome.path)
253            } else {
254                format!("{} (expected and missing)", outcome.path)
255            };
256            missing.push(label);
257            continue;
258        }
259        let action = match (outcome.kind, outcome.disposition) {
260            (Kind::Rendered | Kind::Seeded, Disposition::Unchanged) => "matches",
261            (Kind::Rendered, _) => {
262                mismatches.push(outcome.path.clone());
263                "differs"
264            }
265            (Kind::Seeded, _) => "differs",
266            (Kind::State, _) => "state",
267        };
268        files.push(FileEntry {
269            path: outcome.path.clone(),
270            kind: outcome.kind.as_str(),
271            action,
272        });
273    }
274    if mismatches.is_empty() && missing.is_empty() && defects.is_empty() {
275        return Ok(files);
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/6` shape, held by snapshot.
315    #[test]
316    fn the_adopt_report_schema_snapshot_holds() {
317        let report = Report {
318            schema: "rk.adopt/6",
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            plan: None,
339            next: vec!["commit the config and the record".into()],
340        };
341        assert_eq!(
342            serde_json::to_string(&report).expect("a report serializes"),
343            r#"{"schema":"rk.adopt/6","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"]}"#
344        );
345    }
346}