1use serde::Serialize;
19
20use crate::cli::adopt::AdoptArgs;
21use crate::diagnostic::{Diagnostic, Reason};
22use crate::error::RkError;
23use crate::held;
24use crate::landing::apply::{self, Adoption, Prepared, Replaced};
25use crate::landing::manifest::{self, CheckoutMode, Provider};
26use crate::landing::{self, lock};
27use crate::output::Output;
28use crate::profile::{CapabilityRequests, GitWorkflow, ProfileSnapshot};
29use crate::stage::CapabilityNote;
30
31#[derive(Debug, Serialize)]
33struct FileEntry {
34 path: String,
36 kind: &'static str,
38 action: &'static str,
40}
41
42#[derive(Debug, Serialize)]
44struct Report {
45 schema: &'static str,
47 mode: &'static str,
49 target: String,
51 profile: ProfileSnapshot,
53 git: GitWorkflow,
55 capabilities: CapabilityRequests,
57 repo: String,
59 selection: Vec<CapabilityNote>,
61 #[serde(skip_serializing_if = "Option::is_none")]
64 release_unavailable: Option<String>,
65 #[serde(skip_serializing_if = "Option::is_none")]
69 licence_refusal: Option<String>,
70 #[serde(skip_serializing_if = "Option::is_none")]
73 withheld: Option<Vec<landing::Withheld>>,
74 config: crate::config::Plan,
75 files: Vec<FileEntry>,
77 replaced: Vec<Replaced>,
80 next: Vec<String>,
82}
83
84#[allow(
94 clippy::too_many_lines,
95 reason = "one pass holds the target, resolves the answers once, verifies, and lands"
96)]
97pub fn run(args: &AdoptArgs) -> Result<(), RkError> {
98 let out = Output::new(args.json);
99 if !args.target.is_dir() {
100 return Err(RkError::missing(
101 Diagnostic::new(
102 Reason::TargetNotFound,
103 format!("target {} is not a directory", args.target),
104 )
105 .expected("an existing repository to adopt"),
106 ));
107 }
108 let lock = args
112 .apply
113 .then(|| lock::acquire(&args.target))
114 .transpose()?;
115 held::pause(apply::PAUSE_VAR, "locked", "proceed-locked");
120 let held = lock.as_ref().map_or_else(
121 || apply::Held::open(&args.target),
122 |lock| apply::Held::open_locked(&args.target, lock),
123 )?;
124 held::pause(apply::PAUSE_VAR, "held", "proceed-held");
126 if landing::manifest::load(held.base())?.is_some() {
127 return Err(RkError::refusal(
128 Diagnostic::new(
129 Reason::StateDrift,
130 format!(
131 "{} already carries {}; it needs no adoption",
132 args.target,
133 manifest::MANIFEST_PATH
134 ),
135 )
136 .expected("a target without a landing receipt")
137 .action(format!(
138 "rk upgrade --target {} takes it to this binary's projection",
139 args.target
140 ))
141 .target_state("unchanged"),
142 ));
143 }
144 let config = crate::config::load(held.base().as_std_path())?;
145 let staged = args
146 .stage
147 .as_deref()
148 .map(|root| {
149 if answered(args) {
150 return Err(crate::stage::read::answered_twice());
151 }
152 let canonical = std::fs::canonicalize(args.target.as_std_path())?;
153 crate::stage::read::load(root, &canonical).map(|receipt| (root, receipt))
154 })
155 .transpose()?;
156 let staged_inputs = staged
157 .as_ref()
158 .map(|(_, receipt)| crate::stage::read::inputs(&receipt.parameters));
159 let flag_inputs = landing::Inputs {
160 nix: args.nix_packaging.then_some(true),
161 reporting_policy: args.reporting_policy.then_some(true),
162 scorecard: args.scorecard.then_some(true),
163 workflow_guards: args.workflow_guards.then_some(true),
164 code_scanning: args
165 .code_scanning
166 .as_deref()
167 .map(Provider::parse)
168 .transpose()?,
169 ..args.profile.inputs()?
170 };
171 let params = landing::Params::resolve(
172 held.base(),
173 staged_inputs.as_ref().unwrap_or(&flag_inputs),
174 config.as_ref(),
175 None,
176 landing::Purpose::Adopt,
177 )?;
178 if let Some((root, receipt)) = &staged {
179 crate::stage::read::confirm(¶ms, receipt, root)?;
180 }
181 let checkout_mode = params.checkout_mode();
182
183 let mut prepared = apply::prepare(&held, None, ¶ms, config.as_ref())?;
184 let files = verify(&held, checkout_mode, &mut prepared, &args.replace)?;
185 prepared.collisions.clear();
189
190 out.result_line(format!(
191 "profile: {}",
192 crate::commands::profile::describe(
193 params.profile(),
194 params.git(),
195 params.capabilities(),
196 params.repo()
197 )
198 ));
199 crate::commands::init::describe_selection(out, &prepared);
200 for file in &files {
201 out.result_line(match file.action {
202 "differs" => format!("differs {} (seeded, target-owned)", file.path),
203 action => format!("{action} {}", file.path),
204 });
205 }
206 for entry in &prepared.projection.omissions {
207 out.result_line(format!("withheld {}: {}", entry.destination, entry.reason));
208 }
209
210 crate::commands::init::report_moved_secrets(out, &held, &prepared)?;
211 let replaced = if let Some(lock) = &lock {
212 let landed = apply::land(&held, None, &prepared, apply::Origin::Adopt, lock)?;
213 for entry in &landed.replaced {
214 if let Some(saved) = &entry.saved_to {
215 out.result_line(format!("saved {} to {saved}", entry.path));
216 }
217 }
218 out.result_line(format!("wrote {}", manifest::MANIFEST_PATH));
219 landed.replaced
220 } else {
221 apply::previewed_replacements(&prepared)
222 };
223 drop(lock);
224 report(out, args, ¶ms, &prepared, files, replaced)
225}
226
227const SCHEMA: &str = "rk.adopt/11";
229
230const fn answered(args: &AdoptArgs) -> bool {
232 !args.profile.is_empty()
233 || args.nix_packaging
234 || args.reporting_policy
235 || args.scorecard
236 || args.workflow_guards
237 || args.code_scanning.is_some()
238}
239
240fn report(
243 out: Output,
244 args: &AdoptArgs,
245 params: &landing::Params,
246 prepared: &Prepared,
247 files: Vec<FileEntry>,
248 replaced: Vec<Replaced>,
249) -> Result<(), RkError> {
250 let repo = params.repo().to_owned();
251 let mut next = if args.apply {
252 vec![
253 "commit the config and the receipt".to_owned(),
254 format!("rk status --target {} reports this landing", args.target),
255 ]
256 } else {
257 vec![format!(
258 "rk adopt{}{} --target {} --apply writes the config and the receipt inside .release-kit/",
259 params.canonical_flags(),
260 params.capability_flags(),
261 args.target
262 )]
263 };
264 if let Some(reason) = prepared.projection.release_unavailable() {
265 next.insert(
266 0,
267 format!("the apply refuses until the release automation resolves: {reason}"),
268 );
269 }
270 if let Some(reason) = prepared.projection.licence_refusal.as_deref() {
271 next.insert(
272 0,
273 format!("the apply refuses until the licence condition is answered: {reason}"),
274 );
275 }
276 out.result_line(format!(
277 "{} {}\n{}",
278 prepared.config.action,
279 crate::config::CONFIG_PATH,
280 prepared.config.content
281 ));
282 out.next(&next);
283 out.emit(&Report {
284 schema: SCHEMA,
285 config: prepared.config.clone(),
286 mode: if args.apply { "apply" } else { "preview" },
287 target: args.target.to_string(),
288 profile: params.profile().clone(),
289 git: params.git().clone(),
290 capabilities: params.capabilities().clone(),
291 repo,
292 selection: prepared
293 .projection
294 .capabilities
295 .iter()
296 .map(|selection| CapabilityNote::of(selection, &prepared.projection))
297 .collect(),
298 release_unavailable: prepared.projection.release_unavailable().map(str::to_owned),
299 licence_refusal: prepared.projection.licence_refusal.clone(),
300 withheld: {
301 let withheld: Vec<landing::Withheld> = prepared
302 .projection
303 .omissions
304 .iter()
305 .map(|omission| landing::Withheld {
306 path: omission.destination.clone(),
307 reason: omission.reason.clone(),
308 })
309 .collect();
310 (!withheld.is_empty()).then_some(withheld)
311 },
312 files,
313 replaced,
314 next,
315 })
316}
317
318#[allow(
326 clippy::too_many_lines,
327 reason = "one pass judges every candidate, the named conflicts, and the one refusal"
328)]
329fn verify(
330 held: &apply::Held,
331 checkout_mode: CheckoutMode,
332 prepared: &mut Prepared,
333 replace: &[String],
334) -> Result<Vec<FileEntry>, RkError> {
335 let named = |destination: &str| replace.iter().any(|path| path == destination);
336 let mut answered: Vec<String> = Vec::new();
337 let target = held.display();
338 let mut mismatches: Vec<String> = Vec::new();
339 let mut missing: Vec<String> = Vec::new();
340 let mut files = Vec::new();
341 let defects: Vec<String> = prepared
344 .projection
345 .collisions
346 .iter()
347 .map(|collision| collision.reason.clone())
348 .collect();
349 for candidate in &prepared.projection.candidates {
350 let judged = apply::judge_adoption(held, candidate)?;
351 let answers = judged.replaceable(candidate.kind) && named(&candidate.destination);
352 let action = match judged {
353 Adoption::Irregular => {
354 mismatches.push(format!("{} (is not a regular file)", candidate.destination));
355 continue;
356 }
357 Adoption::Missing { exists } => {
358 if answers {
359 answered.push(candidate.destination.clone());
360 files.push(FileEntry {
361 path: candidate.destination.clone(),
362 kind: candidate.kind.as_str(),
363 action: "replaced",
364 });
365 continue;
366 }
367 missing.push(if exists {
370 format!("{} (carries no release-kit block)", candidate.destination)
371 } else {
372 format!("{} (expected and missing)", candidate.destination)
373 });
374 continue;
375 }
376 Adoption::Matches => "matches",
377 Adoption::Differs if answers => {
378 answered.push(candidate.destination.clone());
379 "replaced"
380 }
381 Adoption::Differs => {
382 mismatches.push(format!(
383 "{} (differs from the rendered candidate)",
384 candidate.destination
385 ));
386 "differs"
387 }
388 Adoption::Tuned => "differs",
389 Adoption::State => "state",
390 };
391 files.push(FileEntry {
392 path: candidate.destination.clone(),
393 kind: candidate.kind.as_str(),
394 action,
395 });
396 }
397 if let Some(stray) = replace.iter().find(|path| !answered.contains(path)) {
398 return Err(RkError::Usage(format!(
399 "--replace {stray} names no conflict this adoption has; a conflict is a rendered destination that differs from the candidate or is missing"
400 )));
401 }
402 if mismatches.is_empty() && missing.is_empty() && defects.is_empty() {
403 prepared.replacements = answered;
404 return Ok(files);
405 }
406 let listed: Vec<String> = mismatches
407 .iter()
408 .cloned()
409 .chain(missing.iter().cloned())
410 .chain(defects.iter().cloned())
411 .collect();
412 Err(RkError::refusal(
413 Diagnostic::new(
414 Reason::StateDrift,
415 format!(
416 "this target is not adoptable as-is, and no receipt was written: {}",
417 listed.join(", ")
418 ),
419 )
420 .expected(format!(
421 "every rendered destination matching the {} candidate, byte for byte",
422 checkout_mode.as_str()
423 ))
424 .action(format!(
425 "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 --checkout-mode, --integration, or --release-style{}",
426 target,
427 if mismatches.iter().any(|path| path.starts_with("SECURITY.md ")) {
431 ". 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"
432 } else {
433 ""
434 }
435 ))
436 .target_state("unchanged"),
437 ))
438}
439
440#[cfg(test)]
441mod tests {
442 use super::{FileEntry, Report};
443 use crate::landing::{CheckoutMode, Integration};
444 use crate::profile::{
445 CapabilityRequests, GitWorkflow, ProfileSnapshot, ReleaseIntent, ReleaseMode,
446 };
447
448 #[test]
450 fn the_adopt_report_schema_snapshot_holds() {
451 let report = Report {
452 schema: super::SCHEMA,
453 config: crate::config::Plan {
454 action: "added",
455 changes: vec![],
456 content: "schema_version = 2\n".into(),
457 },
458 mode: "apply",
459 target: "/tmp/t".into(),
460 profile: ProfileSnapshot {
461 technologies: vec!["rust".into()],
462 forge: Some("github".into()),
463 release: ReleaseIntent {
464 mode: ReleaseMode::Automatic,
465 driver: Some("rust".into()),
466 style: Some(crate::landing::Style::Trunk),
467 line_prefix: Some("release/".into()),
468 registry: None,
469 build: None,
470 },
471 },
472 git: GitWorkflow {
473 trunk: "master".into(),
474 checkout_mode: CheckoutMode::MainWorktree,
475 integration: Integration::Local,
476 merge_form: crate::landing::MergeForm::Squash,
477 },
478 capabilities: CapabilityRequests {
479 nix_packaging: false,
480 reporting_policy: true,
481 scorecard: false,
482 code_scanning: Some(crate::landing::Provider::Semgrep),
483 workflow_guards: Some(false),
484 },
485 repo: "acme/widget".into(),
486 selection: vec![],
487 release_unavailable: None,
488 licence_refusal: None,
489 withheld: None,
490 files: vec![FileEntry {
491 path: "release-plz.toml".into(),
492 kind: "seeded",
493 action: "differs",
494 }],
495 replaced: vec![],
496 next: vec!["commit the config and the receipt".into()],
497 };
498 assert_eq!(
499 serde_json::to_string(&report).expect("a report serializes"),
500 r#"{"schema":"rk.adopt/11","mode":"apply","target":"/tmp/t","profile":{"technologies":["rust"],"forge":"github","release":{"mode":"automatic","driver":"rust","style":"trunk","line_prefix":"release/"}},"git":{"trunk":"master","checkout_mode":"main-worktree","integration":"local","merge_form":"squash"},"capabilities":{"nix_packaging":false,"reporting_policy":true,"scorecard":false,"code_scanning":"semgrep","workflow_guards":false},"repo":"acme/widget","selection":[],"config":{"action":"added","changes":[],"content":"schema_version = 2\n"},"files":[{"path":"release-plz.toml","kind":"seeded","action":"differs"}],"replaced":[],"next":["commit the config and the receipt"]}"#
501 );
502 }
503}