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