1use serde::Serialize;
17
18use crate::cli::adopt::AdoptArgs;
19use crate::diagnostic::{Diagnostic, Reason};
20use crate::error::RkError;
21use crate::held;
22use crate::landing::apply::{self, Prepared};
23use crate::landing::manifest::{self, Style, Workflow};
24use crate::landing::{self, Kind, lock};
25use crate::output::Output;
26use crate::projection::Placement;
27
28#[derive(Debug, Serialize)]
30struct FileEntry {
31 path: String,
33 kind: &'static str,
35 action: &'static str,
37}
38
39#[derive(Debug, Serialize)]
41struct Report {
42 schema: &'static str,
44 mode: &'static str,
46 target: String,
48 tech: String,
50 forge: String,
52 repo: String,
54 workflow: &'static str,
57 style: &'static str,
58 nix: bool,
60 #[serde(skip_serializing_if = "Option::is_none")]
63 withheld: Option<Vec<landing::Withheld>>,
64 config: crate::config::Plan,
65 files: Vec<FileEntry>,
67 next: Vec<String>,
69}
70
71pub fn run(args: &AdoptArgs) -> Result<(), RkError> {
81 let out = Output::new(args.json);
82 if !args.target.is_dir() {
83 return Err(RkError::missing(
84 Diagnostic::new(
85 Reason::TargetNotFound,
86 format!("target {} is not a directory", args.target),
87 )
88 .expected("an existing repository to adopt"),
89 ));
90 }
91 let lock = args
95 .apply
96 .then(|| lock::acquire(&args.target))
97 .transpose()?;
98 held::pause(apply::PAUSE_VAR, "locked", "proceed-locked");
103 let held = lock.as_ref().map_or_else(
104 || apply::Held::open(&args.target),
105 |lock| apply::Held::open_locked(&args.target, lock),
106 )?;
107 held::pause(apply::PAUSE_VAR, "held", "proceed-held");
109 if landing::manifest::load(held.base())?.is_some() {
110 return Err(RkError::refusal(
111 Diagnostic::new(
112 Reason::StateDrift,
113 format!(
114 "{} already carries {}; it needs no adoption",
115 args.target,
116 manifest::MANIFEST_PATH
117 ),
118 )
119 .expected("a target without a landing receipt")
120 .action(format!(
121 "rk upgrade --target {} takes it to this binary's projection",
122 args.target
123 ))
124 .target_state("unchanged"),
125 ));
126 }
127 let config = crate::config::load(held.base().as_std_path())?;
128 let params = landing::Params::resolve(
129 held.base(),
130 &landing::Inputs {
131 tech: args.tech.as_deref(),
132 forge: args.forge.as_deref(),
133 repo: args.repo.as_deref(),
134 workflow: args.workflow.as_deref().map(Workflow::parse).transpose()?,
135 style: args.style.as_deref().map(Style::parse).transpose()?,
136 nix: args.nix.then_some(true),
137 },
138 config.as_ref(),
139 None,
140 landing::Purpose::Adopt,
141 )?;
142 let workflow = params.workflow();
143 let style = params
144 .style()
145 .ok_or_else(|| RkError::Usage("landing style is unresolved".into()))?;
146
147 let mut prepared = apply::prepare(&held, None, ¶ms, config.as_ref())?;
148 let files = verify(&held, workflow, &prepared)?;
149 prepared.collisions.clear();
153
154 for file in &files {
155 out.result_line(match file.action {
156 "differs" => format!("differs {} (seeded, target-owned)", file.path),
157 action => format!("{action} {}", file.path),
158 });
159 }
160 for entry in &prepared.projection.omissions {
161 out.result_line(format!("withheld {}: {}", entry.destination, entry.reason));
162 }
163
164 if let Some(lock) = &lock {
165 apply::land(&held, None, &prepared, apply::Origin::Adopt, lock)?;
166 out.result_line(format!("wrote {}", manifest::MANIFEST_PATH));
167 }
168 drop(lock);
169 report(out, args, ¶ms, style, &prepared, files)
170}
171
172fn report(
175 out: Output,
176 args: &AdoptArgs,
177 params: &landing::Params,
178 style: Style,
179 prepared: &Prepared,
180 files: Vec<FileEntry>,
181) -> Result<(), RkError> {
182 let tech = params.tech().to_owned();
183 let repo = params.repo().to_owned();
184 let workflow = params.workflow();
185 let next = if args.apply {
186 vec![
187 "commit the config and the receipt".to_owned(),
188 format!("rk status --target {} reports this landing", args.target),
189 ]
190 } else {
191 vec![format!(
192 "rk adopt --tech {tech} --forge {} --repo {repo} --workflow {} --style {}{} --target {} --apply writes the config and the receipt inside .release-kit/",
193 params.forge(),
194 workflow.as_str(),
195 style.as_str(),
196 if params.nix() { " --nix" } else { "" },
197 args.target
198 )]
199 };
200 out.result_line(format!(
201 "{} {}\n{}",
202 prepared.config.action,
203 crate::config::CONFIG_PATH,
204 prepared.config.content
205 ));
206 out.next(&next);
207 out.emit(&Report {
208 schema: "rk.adopt/7",
209 config: prepared.config.clone(),
210 mode: if args.apply { "apply" } else { "preview" },
211 target: args.target.to_string(),
212 tech,
213 forge: params.forge().to_owned(),
214 repo,
215 workflow: workflow.as_str(),
216 style: style.as_str(),
217 nix: params.nix(),
218 withheld: {
219 let withheld: Vec<landing::Withheld> = prepared
220 .projection
221 .omissions
222 .iter()
223 .map(|omission| landing::Withheld {
224 path: omission.destination.clone(),
225 reason: omission.reason.clone(),
226 })
227 .collect();
228 (!withheld.is_empty()).then_some(withheld)
229 },
230 files,
231 next,
232 })
233}
234
235fn verify(
239 held: &apply::Held,
240 workflow: Workflow,
241 prepared: &Prepared,
242) -> Result<Vec<FileEntry>, RkError> {
243 let target = held.display();
244 let mut mismatches: Vec<String> = Vec::new();
245 let mut missing: Vec<String> = Vec::new();
246 let mut files = Vec::new();
247 let defects: Vec<String> = prepared
250 .projection
251 .collisions
252 .iter()
253 .map(|collision| collision.reason.clone())
254 .collect();
255 for candidate in &prepared.projection.candidates {
256 let path = held.base().join(&candidate.destination);
257 let regular = std::fs::symlink_metadata(path.as_std_path())
258 .is_ok_and(|metadata| metadata.is_file())
259 || !path.exists();
260 if !regular {
261 mismatches.push(format!("{} (is not a regular file)", candidate.destination));
262 continue;
263 }
264 let current = landing::read_recorded(held.base(), &candidate.destination)?;
265 let Some(current) = current else {
266 let label = if path.exists() {
269 format!("{} (carries no release-kit block)", candidate.destination)
270 } else {
271 format!("{} (expected and missing)", candidate.destination)
272 };
273 missing.push(label);
274 continue;
275 };
276 let expected: &[u8] = match candidate.placement {
277 Placement::Whole => &candidate.bytes,
278 Placement::Region { .. } => candidate.region.as_deref().unwrap_or(&candidate.bytes),
279 };
280 let action = match candidate.kind {
281 Kind::Rendered | Kind::Seeded if current == expected => "matches",
282 Kind::Rendered => {
283 mismatches.push(format!(
284 "{} (differs from the rendered candidate)",
285 candidate.destination
286 ));
287 "differs"
288 }
289 Kind::Seeded => "differs",
290 Kind::State => "state",
291 };
292 files.push(FileEntry {
293 path: candidate.destination.clone(),
294 kind: candidate.kind.as_str(),
295 action,
296 });
297 }
298 if mismatches.is_empty() && missing.is_empty() && defects.is_empty() {
299 return Ok(files);
300 }
301 let listed: Vec<String> = mismatches
302 .iter()
303 .cloned()
304 .chain(missing.iter().cloned())
305 .chain(defects.iter().cloned())
306 .collect();
307 Err(RkError::refusal(
308 Diagnostic::new(
309 Reason::StateDrift,
310 format!(
311 "this target is not adoptable as-is, and no receipt was written: {}",
312 listed.join(", ")
313 ),
314 )
315 .expected(format!(
316 "every rendered destination matching the {} candidate, byte for byte",
317 workflow.as_str()
318 ))
319 .action(format!(
320 "align first: rk stage --target {} stages the candidate for a byte comparison, and the rk-setup skill carries the migration that brings each destination to it; then re-run, or select the other candidate with --workflow or --style{}",
321 target,
322 if mismatches.iter().any(|path| path.starts_with("SECURITY.md ")) {
326 ". 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"
327 } else {
328 ""
329 }
330 ))
331 .target_state("unchanged"),
332 ))
333}
334
335#[cfg(test)]
336mod tests {
337 use super::{FileEntry, Report};
338
339 #[test]
341 fn the_adopt_report_schema_snapshot_holds() {
342 let report = Report {
343 schema: "rk.adopt/7",
344 config: crate::config::Plan {
345 action: "added",
346 changes: vec![],
347 content: "schema_version = 1\n".into(),
348 },
349 mode: "apply",
350 target: "/tmp/t".into(),
351 tech: "rust".into(),
352 forge: "github".into(),
353 repo: "acme/widget".into(),
354 workflow: "branches",
355 style: "trunk",
356 nix: false,
357 withheld: None,
358 files: vec![FileEntry {
359 path: "release-plz.toml".into(),
360 kind: "seeded",
361 action: "differs",
362 }],
363 next: vec!["commit the config and the receipt".into()],
364 };
365 assert_eq!(
366 serde_json::to_string(&report).expect("a report serializes"),
367 r#"{"schema":"rk.adopt/7","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 receipt"]}"#
368 );
369 }
370}