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 let replaced = if let Some(lock) = &lock {
211 let landed = apply::land(&held, None, &prepared, apply::Origin::Adopt, lock)?;
212 for entry in &landed.replaced {
213 if let Some(saved) = &entry.saved_to {
214 out.result_line(format!("saved {} to {saved}", entry.path));
215 }
216 }
217 out.result_line(format!("wrote {}", manifest::MANIFEST_PATH));
218 landed.replaced
219 } else {
220 apply::previewed_replacements(&prepared)
221 };
222 drop(lock);
223 report(out, args, ¶ms, &prepared, files, replaced)
224}
225
226const SCHEMA: &str = "rk.adopt/11";
228
229const fn answered(args: &AdoptArgs) -> bool {
231 !args.profile.is_empty()
232 || args.nix_packaging
233 || args.reporting_policy
234 || args.scorecard
235 || args.workflow_guards
236 || args.code_scanning.is_some()
237}
238
239fn report(
242 out: Output,
243 args: &AdoptArgs,
244 params: &landing::Params,
245 prepared: &Prepared,
246 files: Vec<FileEntry>,
247 replaced: Vec<Replaced>,
248) -> Result<(), RkError> {
249 let repo = params.repo().to_owned();
250 let mut next = if args.apply {
251 vec![
252 "commit the config and the receipt".to_owned(),
253 format!("rk status --target {} reports this landing", args.target),
254 ]
255 } else {
256 vec![format!(
257 "rk adopt{}{} --target {} --apply writes the config and the receipt inside .release-kit/",
258 params.canonical_flags(),
259 params.capability_flags(),
260 args.target
261 )]
262 };
263 if let Some(reason) = prepared.projection.release_unavailable() {
264 next.insert(
265 0,
266 format!("the apply refuses until the release automation resolves: {reason}"),
267 );
268 }
269 if let Some(reason) = prepared.projection.licence_refusal.as_deref() {
270 next.insert(
271 0,
272 format!("the apply refuses until the licence condition is answered: {reason}"),
273 );
274 }
275 out.result_line(format!(
276 "{} {}\n{}",
277 prepared.config.action,
278 crate::config::CONFIG_PATH,
279 prepared.config.content
280 ));
281 out.next(&next);
282 out.emit(&Report {
283 schema: SCHEMA,
284 config: prepared.config.clone(),
285 mode: if args.apply { "apply" } else { "preview" },
286 target: args.target.to_string(),
287 profile: params.profile().clone(),
288 git: params.git().clone(),
289 capabilities: params.capabilities().clone(),
290 repo,
291 selection: prepared
292 .projection
293 .capabilities
294 .iter()
295 .map(|selection| CapabilityNote::of(selection, &prepared.projection))
296 .collect(),
297 release_unavailable: prepared.projection.release_unavailable().map(str::to_owned),
298 licence_refusal: prepared.projection.licence_refusal.clone(),
299 withheld: {
300 let withheld: Vec<landing::Withheld> = prepared
301 .projection
302 .omissions
303 .iter()
304 .map(|omission| landing::Withheld {
305 path: omission.destination.clone(),
306 reason: omission.reason.clone(),
307 })
308 .collect();
309 (!withheld.is_empty()).then_some(withheld)
310 },
311 files,
312 replaced,
313 next,
314 })
315}
316
317#[allow(
325 clippy::too_many_lines,
326 reason = "one pass judges every candidate, the named conflicts, and the one refusal"
327)]
328fn verify(
329 held: &apply::Held,
330 checkout_mode: CheckoutMode,
331 prepared: &mut Prepared,
332 replace: &[String],
333) -> Result<Vec<FileEntry>, RkError> {
334 let named = |destination: &str| replace.iter().any(|path| path == destination);
335 let mut answered: Vec<String> = Vec::new();
336 let target = held.display();
337 let mut mismatches: Vec<String> = Vec::new();
338 let mut missing: Vec<String> = Vec::new();
339 let mut files = Vec::new();
340 let defects: Vec<String> = prepared
343 .projection
344 .collisions
345 .iter()
346 .map(|collision| collision.reason.clone())
347 .collect();
348 for candidate in &prepared.projection.candidates {
349 let judged = apply::judge_adoption(held, candidate)?;
350 let answers = judged.replaceable(candidate.kind) && named(&candidate.destination);
351 let action = match judged {
352 Adoption::Irregular => {
353 mismatches.push(format!("{} (is not a regular file)", candidate.destination));
354 continue;
355 }
356 Adoption::Missing { exists } => {
357 if answers {
358 answered.push(candidate.destination.clone());
359 files.push(FileEntry {
360 path: candidate.destination.clone(),
361 kind: candidate.kind.as_str(),
362 action: "replaced",
363 });
364 continue;
365 }
366 missing.push(if exists {
369 format!("{} (carries no release-kit block)", candidate.destination)
370 } else {
371 format!("{} (expected and missing)", candidate.destination)
372 });
373 continue;
374 }
375 Adoption::Matches => "matches",
376 Adoption::Differs if answers => {
377 answered.push(candidate.destination.clone());
378 "replaced"
379 }
380 Adoption::Differs => {
381 mismatches.push(format!(
382 "{} (differs from the rendered candidate)",
383 candidate.destination
384 ));
385 "differs"
386 }
387 Adoption::Tuned => "differs",
388 Adoption::State => "state",
389 };
390 files.push(FileEntry {
391 path: candidate.destination.clone(),
392 kind: candidate.kind.as_str(),
393 action,
394 });
395 }
396 if let Some(stray) = replace.iter().find(|path| !answered.contains(path)) {
397 return Err(RkError::Usage(format!(
398 "--replace {stray} names no conflict this adoption has; a conflict is a rendered destination that differs from the candidate or is missing"
399 )));
400 }
401 if mismatches.is_empty() && missing.is_empty() && defects.is_empty() {
402 prepared.replacements = answered;
403 return Ok(files);
404 }
405 let listed: Vec<String> = mismatches
406 .iter()
407 .cloned()
408 .chain(missing.iter().cloned())
409 .chain(defects.iter().cloned())
410 .collect();
411 Err(RkError::refusal(
412 Diagnostic::new(
413 Reason::StateDrift,
414 format!(
415 "this target is not adoptable as-is, and no receipt was written: {}",
416 listed.join(", ")
417 ),
418 )
419 .expected(format!(
420 "every rendered destination matching the {} candidate, byte for byte",
421 checkout_mode.as_str()
422 ))
423 .action(format!(
424 "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{}",
425 target,
426 if mismatches.iter().any(|path| path.starts_with("SECURITY.md ")) {
430 ". 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"
431 } else {
432 ""
433 }
434 ))
435 .target_state("unchanged"),
436 ))
437}
438
439#[cfg(test)]
440mod tests {
441 use super::{FileEntry, Report};
442 use crate::landing::{CheckoutMode, Integration};
443 use crate::profile::{
444 CapabilityRequests, GitWorkflow, ProfileSnapshot, ReleaseIntent, ReleaseMode,
445 };
446
447 #[test]
449 fn the_adopt_report_schema_snapshot_holds() {
450 let report = Report {
451 schema: super::SCHEMA,
452 config: crate::config::Plan {
453 action: "added",
454 changes: vec![],
455 content: "schema_version = 2\n".into(),
456 },
457 mode: "apply",
458 target: "/tmp/t".into(),
459 profile: ProfileSnapshot {
460 technologies: vec!["rust".into()],
461 forge: Some("github".into()),
462 release: ReleaseIntent {
463 mode: ReleaseMode::Automatic,
464 driver: Some("rust".into()),
465 style: Some(crate::landing::Style::Trunk),
466 line_prefix: Some("release/".into()),
467 },
468 },
469 git: GitWorkflow {
470 trunk: "master".into(),
471 checkout_mode: CheckoutMode::MainWorktree,
472 integration: Integration::Local,
473 merge_form: crate::landing::MergeForm::Squash,
474 },
475 capabilities: CapabilityRequests {
476 nix_packaging: false,
477 reporting_policy: true,
478 scorecard: false,
479 code_scanning: Some(crate::landing::Provider::Semgrep),
480 workflow_guards: Some(false),
481 },
482 repo: "acme/widget".into(),
483 selection: vec![],
484 release_unavailable: None,
485 licence_refusal: None,
486 withheld: None,
487 files: vec![FileEntry {
488 path: "release-plz.toml".into(),
489 kind: "seeded",
490 action: "differs",
491 }],
492 replaced: vec![],
493 next: vec!["commit the config and the receipt".into()],
494 };
495 assert_eq!(
496 serde_json::to_string(&report).expect("a report serializes"),
497 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"]}"#
498 );
499 }
500}