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, Provider, 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 scorecard: bool,
62 #[serde(skip_serializing_if = "Option::is_none")]
65 code_scanning: Option<&'static str>,
66 #[serde(skip_serializing_if = "Option::is_none")]
70 licence_refusal: Option<String>,
71 #[serde(skip_serializing_if = "Option::is_none")]
74 withheld: Option<Vec<landing::Withheld>>,
75 config: crate::config::Plan,
76 files: Vec<FileEntry>,
78 next: Vec<String>,
80}
81
82pub fn run(args: &AdoptArgs) -> Result<(), RkError> {
92 let out = Output::new(args.json);
93 if !args.target.is_dir() {
94 return Err(RkError::missing(
95 Diagnostic::new(
96 Reason::TargetNotFound,
97 format!("target {} is not a directory", args.target),
98 )
99 .expected("an existing repository to adopt"),
100 ));
101 }
102 let lock = args
106 .apply
107 .then(|| lock::acquire(&args.target))
108 .transpose()?;
109 held::pause(apply::PAUSE_VAR, "locked", "proceed-locked");
114 let held = lock.as_ref().map_or_else(
115 || apply::Held::open(&args.target),
116 |lock| apply::Held::open_locked(&args.target, lock),
117 )?;
118 held::pause(apply::PAUSE_VAR, "held", "proceed-held");
120 if landing::manifest::load(held.base())?.is_some() {
121 return Err(RkError::refusal(
122 Diagnostic::new(
123 Reason::StateDrift,
124 format!(
125 "{} already carries {}; it needs no adoption",
126 args.target,
127 manifest::MANIFEST_PATH
128 ),
129 )
130 .expected("a target without a landing receipt")
131 .action(format!(
132 "rk upgrade --target {} takes it to this binary's projection",
133 args.target
134 ))
135 .target_state("unchanged"),
136 ));
137 }
138 let config = crate::config::load(held.base().as_std_path())?;
139 let params = landing::Params::resolve(
140 held.base(),
141 &landing::Inputs {
142 tech: args.tech.as_deref(),
143 forge: args.forge.as_deref(),
144 repo: args.repo.as_deref(),
145 workflow: args.workflow.as_deref().map(Workflow::parse).transpose()?,
146 style: args.style.as_deref().map(Style::parse).transpose()?,
147 nix: args.nix.then_some(true),
148 scorecard: args.scorecard.then_some(true),
149 code_scanning: args
150 .code_scanning
151 .as_deref()
152 .map(Provider::parse)
153 .transpose()?,
154 },
155 config.as_ref(),
156 None,
157 landing::Purpose::Adopt,
158 )?;
159 let workflow = params.workflow();
160 let style = params
161 .style()
162 .ok_or_else(|| RkError::Usage("landing style is unresolved".into()))?;
163
164 let mut prepared = apply::prepare(&held, None, ¶ms, config.as_ref())?;
165 let files = verify(&held, workflow, &prepared)?;
166 prepared.collisions.clear();
170
171 for file in &files {
172 out.result_line(match file.action {
173 "differs" => format!("differs {} (seeded, target-owned)", file.path),
174 action => format!("{action} {}", file.path),
175 });
176 }
177 for entry in &prepared.projection.omissions {
178 out.result_line(format!("withheld {}: {}", entry.destination, entry.reason));
179 }
180
181 if let Some(lock) = &lock {
182 apply::land(&held, None, &prepared, apply::Origin::Adopt, lock)?;
183 out.result_line(format!("wrote {}", manifest::MANIFEST_PATH));
184 }
185 drop(lock);
186 report(out, args, ¶ms, style, &prepared, files)
187}
188
189fn report(
192 out: Output,
193 args: &AdoptArgs,
194 params: &landing::Params,
195 style: Style,
196 prepared: &Prepared,
197 files: Vec<FileEntry>,
198) -> Result<(), RkError> {
199 let tech = params.tech().to_owned();
200 let repo = params.repo().to_owned();
201 let workflow = params.workflow();
202 let mut next = if args.apply {
203 vec![
204 "commit the config and the receipt".to_owned(),
205 format!("rk status --target {} reports this landing", args.target),
206 ]
207 } else {
208 vec![format!(
209 "rk adopt --tech {tech} --forge {} --repo {repo} --workflow {} --style {}{} --target {} --apply writes the config and the receipt inside .release-kit/",
210 params.forge(),
211 workflow.as_str(),
212 style.as_str(),
213 params.capability_flags(),
214 args.target
215 )]
216 };
217 if let Some(reason) = prepared.projection.licence_refusal.as_deref() {
218 next.insert(
219 0,
220 format!("the apply refuses until the licence condition is answered: {reason}"),
221 );
222 }
223 out.result_line(format!(
224 "{} {}\n{}",
225 prepared.config.action,
226 crate::config::CONFIG_PATH,
227 prepared.config.content
228 ));
229 out.next(&next);
230 out.emit(&Report {
231 schema: "rk.adopt/9",
232 config: prepared.config.clone(),
233 mode: if args.apply { "apply" } else { "preview" },
234 target: args.target.to_string(),
235 tech,
236 forge: params.forge().to_owned(),
237 repo,
238 workflow: workflow.as_str(),
239 style: style.as_str(),
240 nix: params.nix(),
241 scorecard: params.scorecard(),
242 code_scanning: params.code_scanning().map(Provider::as_str),
243 licence_refusal: prepared.projection.licence_refusal.clone(),
244 withheld: {
245 let withheld: Vec<landing::Withheld> = prepared
246 .projection
247 .omissions
248 .iter()
249 .map(|omission| landing::Withheld {
250 path: omission.destination.clone(),
251 reason: omission.reason.clone(),
252 })
253 .collect();
254 (!withheld.is_empty()).then_some(withheld)
255 },
256 files,
257 next,
258 })
259}
260
261fn verify(
265 held: &apply::Held,
266 workflow: Workflow,
267 prepared: &Prepared,
268) -> Result<Vec<FileEntry>, RkError> {
269 let target = held.display();
270 let mut mismatches: Vec<String> = Vec::new();
271 let mut missing: Vec<String> = Vec::new();
272 let mut files = Vec::new();
273 let defects: Vec<String> = prepared
276 .projection
277 .collisions
278 .iter()
279 .map(|collision| collision.reason.clone())
280 .collect();
281 for candidate in &prepared.projection.candidates {
282 let path = held.base().join(&candidate.destination);
283 let regular = std::fs::symlink_metadata(path.as_std_path())
284 .is_ok_and(|metadata| metadata.is_file())
285 || !path.exists();
286 if !regular {
287 mismatches.push(format!("{} (is not a regular file)", candidate.destination));
288 continue;
289 }
290 let current = landing::read_recorded(held.base(), &candidate.destination)?;
291 let Some(current) = current else {
292 let label = if path.exists() {
295 format!("{} (carries no release-kit block)", candidate.destination)
296 } else {
297 format!("{} (expected and missing)", candidate.destination)
298 };
299 missing.push(label);
300 continue;
301 };
302 let expected: &[u8] = match candidate.placement {
303 Placement::Whole => &candidate.bytes,
304 Placement::Region { .. } => candidate.region.as_deref().unwrap_or(&candidate.bytes),
305 };
306 let action = match candidate.kind {
307 Kind::Rendered | Kind::Seeded if current == expected => "matches",
308 Kind::Rendered => {
309 mismatches.push(format!(
310 "{} (differs from the rendered candidate)",
311 candidate.destination
312 ));
313 "differs"
314 }
315 Kind::Seeded => "differs",
316 Kind::State => "state",
317 };
318 files.push(FileEntry {
319 path: candidate.destination.clone(),
320 kind: candidate.kind.as_str(),
321 action,
322 });
323 }
324 if mismatches.is_empty() && missing.is_empty() && defects.is_empty() {
325 return Ok(files);
326 }
327 let listed: Vec<String> = mismatches
328 .iter()
329 .cloned()
330 .chain(missing.iter().cloned())
331 .chain(defects.iter().cloned())
332 .collect();
333 Err(RkError::refusal(
334 Diagnostic::new(
335 Reason::StateDrift,
336 format!(
337 "this target is not adoptable as-is, and no receipt was written: {}",
338 listed.join(", ")
339 ),
340 )
341 .expected(format!(
342 "every rendered destination matching the {} candidate, byte for byte",
343 workflow.as_str()
344 ))
345 .action(format!(
346 "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{}",
347 target,
348 if mismatches.iter().any(|path| path.starts_with("SECURITY.md ")) {
352 ". 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"
353 } else {
354 ""
355 }
356 ))
357 .target_state("unchanged"),
358 ))
359}
360
361#[cfg(test)]
362mod tests {
363 use super::{FileEntry, Report};
364
365 #[test]
367 fn the_adopt_report_schema_snapshot_holds() {
368 let report = Report {
369 schema: "rk.adopt/9",
370 config: crate::config::Plan {
371 action: "added",
372 changes: vec![],
373 content: "schema_version = 1\n".into(),
374 },
375 mode: "apply",
376 target: "/tmp/t".into(),
377 tech: "rust".into(),
378 forge: "github".into(),
379 repo: "acme/widget".into(),
380 workflow: "branches",
381 style: "trunk",
382 nix: false,
383 scorecard: false,
384 code_scanning: Some("semgrep"),
385 licence_refusal: None,
386 withheld: None,
387 files: vec![FileEntry {
388 path: "release-plz.toml".into(),
389 kind: "seeded",
390 action: "differs",
391 }],
392 next: vec!["commit the config and the receipt".into()],
393 };
394 assert_eq!(
395 serde_json::to_string(&report).expect("a report serializes"),
396 r#"{"schema":"rk.adopt/9","mode":"apply","target":"/tmp/t","tech":"rust","forge":"github","repo":"acme/widget","workflow":"branches","style":"trunk","nix":false,"scorecard":false,"code_scanning":"semgrep","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"]}"#
397 );
398 }
399}