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, CheckoutMode, Provider};
24use crate::landing::{self, Kind, lock};
25use crate::output::Output;
26use crate::profile::{CapabilityRequests, GitWorkflow, ProfileSnapshot};
27use crate::projection::Placement;
28use crate::stage::CapabilityNote;
29
30#[derive(Debug, Serialize)]
32struct FileEntry {
33 path: String,
35 kind: &'static str,
37 action: &'static str,
39}
40
41#[derive(Debug, Serialize)]
43struct Report {
44 schema: &'static str,
46 mode: &'static str,
48 target: String,
50 profile: ProfileSnapshot,
52 git: GitWorkflow,
54 capabilities: CapabilityRequests,
56 repo: String,
58 selection: Vec<CapabilityNote>,
60 #[serde(skip_serializing_if = "Option::is_none")]
63 release_unavailable: Option<String>,
64 #[serde(skip_serializing_if = "Option::is_none")]
68 licence_refusal: Option<String>,
69 #[serde(skip_serializing_if = "Option::is_none")]
72 withheld: Option<Vec<landing::Withheld>>,
73 config: crate::config::Plan,
74 files: Vec<FileEntry>,
76 next: Vec<String>,
78}
79
80pub fn run(args: &AdoptArgs) -> Result<(), RkError> {
90 let out = Output::new(args.json);
91 if !args.target.is_dir() {
92 return Err(RkError::missing(
93 Diagnostic::new(
94 Reason::TargetNotFound,
95 format!("target {} is not a directory", args.target),
96 )
97 .expected("an existing repository to adopt"),
98 ));
99 }
100 let lock = args
104 .apply
105 .then(|| lock::acquire(&args.target))
106 .transpose()?;
107 held::pause(apply::PAUSE_VAR, "locked", "proceed-locked");
112 let held = lock.as_ref().map_or_else(
113 || apply::Held::open(&args.target),
114 |lock| apply::Held::open_locked(&args.target, lock),
115 )?;
116 held::pause(apply::PAUSE_VAR, "held", "proceed-held");
118 if landing::manifest::load(held.base())?.is_some() {
119 return Err(RkError::refusal(
120 Diagnostic::new(
121 Reason::StateDrift,
122 format!(
123 "{} already carries {}; it needs no adoption",
124 args.target,
125 manifest::MANIFEST_PATH
126 ),
127 )
128 .expected("a target without a landing receipt")
129 .action(format!(
130 "rk upgrade --target {} takes it to this binary's projection",
131 args.target
132 ))
133 .target_state("unchanged"),
134 ));
135 }
136 let config = crate::config::load(held.base().as_std_path())?;
137 let params = landing::Params::resolve(
138 held.base(),
139 &landing::Inputs {
140 nix: args.nix_packaging.then_some(true),
141 reporting_policy: args.reporting_policy.then_some(true),
142 scorecard: args.scorecard.then_some(true),
143 code_scanning: args
144 .code_scanning
145 .as_deref()
146 .map(Provider::parse)
147 .transpose()?,
148 ..args.profile.inputs()?
149 },
150 config.as_ref(),
151 None,
152 landing::Purpose::Adopt,
153 )?;
154 let checkout_mode = params.checkout_mode();
155
156 let mut prepared = apply::prepare(&held, None, ¶ms, config.as_ref())?;
157 let files = verify(&held, checkout_mode, &prepared)?;
158 prepared.collisions.clear();
162
163 out.result_line(format!(
164 "profile: {}",
165 crate::commands::profile::describe(
166 params.profile(),
167 params.git(),
168 params.capabilities(),
169 params.repo()
170 )
171 ));
172 crate::commands::init::describe_selection(out, &prepared);
173 for file in &files {
174 out.result_line(match file.action {
175 "differs" => format!("differs {} (seeded, target-owned)", file.path),
176 action => format!("{action} {}", file.path),
177 });
178 }
179 for entry in &prepared.projection.omissions {
180 out.result_line(format!("withheld {}: {}", entry.destination, entry.reason));
181 }
182
183 if let Some(lock) = &lock {
184 apply::land(&held, None, &prepared, apply::Origin::Adopt, lock)?;
185 out.result_line(format!("wrote {}", manifest::MANIFEST_PATH));
186 }
187 drop(lock);
188 report(out, args, ¶ms, &prepared, files)
189}
190
191fn report(
194 out: Output,
195 args: &AdoptArgs,
196 params: &landing::Params,
197 prepared: &Prepared,
198 files: Vec<FileEntry>,
199) -> Result<(), RkError> {
200 let repo = params.repo().to_owned();
201 let mut next = if args.apply {
202 vec![
203 "commit the config and the receipt".to_owned(),
204 format!("rk status --target {} reports this landing", args.target),
205 ]
206 } else {
207 vec![format!(
208 "rk adopt{}{} --target {} --apply writes the config and the receipt inside .release-kit/",
209 params.canonical_flags(),
210 params.capability_flags(),
211 args.target
212 )]
213 };
214 if let Some(reason) = prepared.projection.release_unavailable() {
215 next.insert(
216 0,
217 format!("the apply refuses until the release automation resolves: {reason}"),
218 );
219 }
220 if let Some(reason) = prepared.projection.licence_refusal.as_deref() {
221 next.insert(
222 0,
223 format!("the apply refuses until the licence condition is answered: {reason}"),
224 );
225 }
226 out.result_line(format!(
227 "{} {}\n{}",
228 prepared.config.action,
229 crate::config::CONFIG_PATH,
230 prepared.config.content
231 ));
232 out.next(&next);
233 out.emit(&Report {
234 schema: "rk.adopt/10",
235 config: prepared.config.clone(),
236 mode: if args.apply { "apply" } else { "preview" },
237 target: args.target.to_string(),
238 profile: params.profile().clone(),
239 git: params.git().clone(),
240 capabilities: params.capabilities().clone(),
241 repo,
242 selection: prepared
243 .projection
244 .capabilities
245 .iter()
246 .map(|selection| CapabilityNote::of(selection, &prepared.projection))
247 .collect(),
248 release_unavailable: prepared.projection.release_unavailable().map(str::to_owned),
249 licence_refusal: prepared.projection.licence_refusal.clone(),
250 withheld: {
251 let withheld: Vec<landing::Withheld> = prepared
252 .projection
253 .omissions
254 .iter()
255 .map(|omission| landing::Withheld {
256 path: omission.destination.clone(),
257 reason: omission.reason.clone(),
258 })
259 .collect();
260 (!withheld.is_empty()).then_some(withheld)
261 },
262 files,
263 next,
264 })
265}
266
267fn verify(
271 held: &apply::Held,
272 checkout_mode: CheckoutMode,
273 prepared: &Prepared,
274) -> Result<Vec<FileEntry>, RkError> {
275 let target = held.display();
276 let mut mismatches: Vec<String> = Vec::new();
277 let mut missing: Vec<String> = Vec::new();
278 let mut files = Vec::new();
279 let defects: Vec<String> = prepared
282 .projection
283 .collisions
284 .iter()
285 .map(|collision| collision.reason.clone())
286 .collect();
287 for candidate in &prepared.projection.candidates {
288 let path = held.base().join(&candidate.destination);
289 let regular = std::fs::symlink_metadata(path.as_std_path())
290 .is_ok_and(|metadata| metadata.is_file())
291 || !path.exists();
292 if !regular {
293 mismatches.push(format!("{} (is not a regular file)", candidate.destination));
294 continue;
295 }
296 let current = landing::read_recorded(held.base(), &candidate.destination)?;
297 let Some(current) = current else {
298 let label = if path.exists() {
301 format!("{} (carries no release-kit block)", candidate.destination)
302 } else {
303 format!("{} (expected and missing)", candidate.destination)
304 };
305 missing.push(label);
306 continue;
307 };
308 let expected: &[u8] = match candidate.placement {
309 Placement::Whole => &candidate.bytes,
310 Placement::Region { .. } => candidate.region.as_deref().unwrap_or(&candidate.bytes),
311 };
312 let action = match candidate.kind {
313 Kind::Rendered | Kind::Seeded if current == expected => "matches",
314 Kind::Rendered => {
315 mismatches.push(format!(
316 "{} (differs from the rendered candidate)",
317 candidate.destination
318 ));
319 "differs"
320 }
321 Kind::Seeded => "differs",
322 Kind::State => "state",
323 };
324 files.push(FileEntry {
325 path: candidate.destination.clone(),
326 kind: candidate.kind.as_str(),
327 action,
328 });
329 }
330 if mismatches.is_empty() && missing.is_empty() && defects.is_empty() {
331 return Ok(files);
332 }
333 let listed: Vec<String> = mismatches
334 .iter()
335 .cloned()
336 .chain(missing.iter().cloned())
337 .chain(defects.iter().cloned())
338 .collect();
339 Err(RkError::refusal(
340 Diagnostic::new(
341 Reason::StateDrift,
342 format!(
343 "this target is not adoptable as-is, and no receipt was written: {}",
344 listed.join(", ")
345 ),
346 )
347 .expected(format!(
348 "every rendered destination matching the {} candidate, byte for byte",
349 checkout_mode.as_str()
350 ))
351 .action(format!(
352 "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 or --release-style{}",
353 target,
354 if mismatches.iter().any(|path| path.starts_with("SECURITY.md ")) {
358 ". 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"
359 } else {
360 ""
361 }
362 ))
363 .target_state("unchanged"),
364 ))
365}
366
367#[cfg(test)]
368mod tests {
369 use super::{FileEntry, Report};
370 use crate::landing::CheckoutMode;
371 use crate::profile::{
372 CapabilityRequests, GitWorkflow, ProfileSnapshot, ReleaseIntent, ReleaseMode,
373 };
374
375 #[test]
377 fn the_adopt_report_schema_snapshot_holds() {
378 let report = Report {
379 schema: "rk.adopt/10",
380 config: crate::config::Plan {
381 action: "added",
382 changes: vec![],
383 content: "schema_version = 2\n".into(),
384 },
385 mode: "apply",
386 target: "/tmp/t".into(),
387 profile: ProfileSnapshot {
388 technologies: vec!["rust".into()],
389 forge: Some("github".into()),
390 release: ReleaseIntent {
391 mode: ReleaseMode::Automatic,
392 driver: Some("rust".into()),
393 style: Some(crate::landing::Style::Trunk),
394 line_prefix: Some("release/".into()),
395 },
396 },
397 git: GitWorkflow {
398 trunk: "master".into(),
399 checkout_mode: CheckoutMode::MainWorktree,
400 },
401 capabilities: CapabilityRequests {
402 nix_packaging: false,
403 reporting_policy: true,
404 scorecard: false,
405 code_scanning: Some(crate::landing::Provider::Semgrep),
406 },
407 repo: "acme/widget".into(),
408 selection: vec![],
409 release_unavailable: None,
410 licence_refusal: None,
411 withheld: None,
412 files: vec![FileEntry {
413 path: "release-plz.toml".into(),
414 kind: "seeded",
415 action: "differs",
416 }],
417 next: vec!["commit the config and the receipt".into()],
418 };
419 assert_eq!(
420 serde_json::to_string(&report).expect("a report serializes"),
421 r#"{"schema":"rk.adopt/10","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"},"capabilities":{"nix_packaging":false,"reporting_policy":true,"scorecard":false,"code_scanning":"semgrep"},"repo":"acme/widget","selection":[],"config":{"action":"added","changes":[],"content":"schema_version = 2\n"},"files":[{"path":"release-plz.toml","kind":"seeded","action":"differs"}],"next":["commit the config and the receipt"]}"#
422 );
423 }
424}