1use serde::Serialize;
14
15use crate::cli::adopt::AdoptArgs;
16use crate::diagnostic::{Diagnostic, Reason};
17use crate::digest::Digest;
18use crate::error::RkError;
19use crate::landing::manifest::{self, FileRecord, Manifest, Parameters, Style, Workflow};
20use crate::landing::{self, Kind};
21use crate::output::Output;
22use crate::registry;
23
24#[derive(Debug, Serialize)]
26struct FileEntry {
27 path: String,
29 kind: &'static str,
31 action: &'static str,
33}
34
35#[derive(Debug, Serialize)]
37struct Report {
38 schema: &'static str,
40 mode: &'static str,
42 target: String,
44 tech: String,
46 forge: String,
48 repo: String,
50 workflow: &'static str,
53 style: &'static str,
54 nix: bool,
56 #[serde(skip_serializing_if = "Option::is_none")]
59 withheld: Option<Vec<landing::Withheld>>,
60 config: crate::config::Plan,
61 files: Vec<FileEntry>,
63 next: Vec<String>,
65}
66
67#[allow(
77 clippy::too_many_lines,
78 reason = "one adopt run is one linear sequence of checks against one target, and cutting it would separate a refusal from the order it is reported in"
79)]
80pub fn run(args: &AdoptArgs) -> Result<(), RkError> {
81 let out = Output::new(args.json);
82 if !args.target.is_dir() {
83 return Err(RkError::missing(
84 Diagnostic::new(
85 Reason::TargetNotFound,
86 format!("target {} is not a directory", args.target),
87 )
88 .expected("an existing repository to adopt"),
89 ));
90 }
91 if landing::manifest::load(&args.target)?.is_some() {
92 return Err(RkError::refusal(
93 Diagnostic::new(
94 Reason::StateDrift,
95 format!(
96 "{} already carries {}; it needs no adoption",
97 args.target,
98 manifest::MANIFEST_PATH
99 ),
100 )
101 .expected("a target without a landing record")
102 .action(format!(
103 "rk upgrade --target {} takes it to this binary's payload",
104 args.target
105 ))
106 .target_state("unchanged"),
107 ));
108 }
109 let config = crate::config::load(args.target.as_std_path())?;
110 let params = landing::Params::resolve(
111 &args.target,
112 &landing::Inputs {
113 tech: args.tech.as_deref(),
114 forge: args.forge.as_deref(),
115 repo: args.repo.as_deref(),
116 workflow: args.workflow.as_deref().map(Workflow::parse).transpose()?,
117 style: args.style.as_deref().map(Style::parse).transpose()?,
118 nix: args.nix.then_some(true),
119 },
120 config.as_ref(),
121 None,
122 landing::Purpose::Adopt,
123 )?;
124 let config =
125 crate::config::Plan::new(args.target.as_std_path(), ¶ms, config.as_ref(), None)?;
126 let tech = params.tech().to_owned();
127 let repo = params.repo().to_owned();
128 let workflow = params.workflow();
129 let style = params
130 .style()
131 .ok_or_else(|| RkError::Usage("landing style is unresolved".into()))?;
132 let mut entries = landing::projection(¶ms)?;
133 let withheld = landing::withhold_nix(&args.target, params.nix(), None, &mut entries)?;
134 let (files, records) = verify(args, workflow, &entries)?;
135
136 for file in &files {
137 out.result_line(match file.action {
138 "differs" => format!("differs {} (seeded, target-owned)", file.path),
139 action => format!("{action} {}", file.path),
140 });
141 }
142 for entry in &withheld {
143 out.result_line(format!("withheld {}: {}", entry.path, entry.reason));
144 }
145
146 if args.apply {
147 config.apply(args.target.as_std_path())?;
148 manifest::write(
149 &args.target,
150 &Manifest {
151 schema_version: manifest::SCHEMA_VERSION,
152 rk_version: env!("CARGO_PKG_VERSION").to_owned(),
153 payload_sha256: crate::commands::payload::report().payload_sha256,
154 origin: "adopt".to_owned(),
155 tech: tech.clone(),
156 forge: params.forge().to_owned(),
157 landed_at: manifest::now(),
158 parameters: Parameters {
159 repo: repo.clone(),
160 workflow,
161 style: Some(style),
162 nix: params.nix(),
163 trunk: params.trunk().to_owned(),
164 line_prefix: params.line_prefix().to_owned(),
165 security_contact: params.security_contact().to_owned(),
166 security_response: params.security_response().to_owned(),
167 },
168 files: records,
169 pins: registry::pins_for(&tech)
170 .into_iter()
171 .map(|pin| (pin.name, pin.version))
172 .collect(),
173 },
174 )?;
175 out.result_line(format!("wrote {}", manifest::MANIFEST_PATH));
176 }
177
178 let next = if args.apply {
179 vec![
180 "commit the config and the record".to_owned(),
181 format!("rk status --target {} reports this landing", args.target),
182 ]
183 } else {
184 vec![format!(
185 "rk adopt --tech {tech} --forge {} --repo {repo} --workflow {} --style {}{} --target {} --apply writes the config and the record inside .release-kit/",
186 params.forge().to_owned(),
187 workflow.as_str(),
188 style.as_str(),
189 if params.nix() { " --nix" } else { "" },
190 args.target
191 )]
192 };
193 out.result_line(format!(
194 "{} {}\n{}",
195 config.action,
196 crate::config::CONFIG_PATH,
197 config.content
198 ));
199 out.next(&next);
200 out.emit(&Report {
201 schema: "rk.adopt/5",
202 config,
203 mode: if args.apply { "apply" } else { "preview" },
204 target: args.target.to_string(),
205 tech,
206 forge: params.forge().to_owned(),
207 repo,
208 workflow: workflow.as_str(),
209 style: style.as_str(),
210 nix: params.nix(),
211 withheld: (!withheld.is_empty()).then_some(withheld),
212 files,
213 next,
214 })
215}
216
217fn verify(
221 args: &AdoptArgs,
222 workflow: Workflow,
223 entries: &[landing::Entry],
224) -> Result<(Vec<FileEntry>, Vec<FileRecord>), RkError> {
225 let mut mismatches: Vec<String> = Vec::new();
226 let mut missing: Vec<String> = Vec::new();
227 let mut files = Vec::new();
228 let mut records = Vec::new();
229 let mut defects: Vec<String> = Vec::new();
232 if let Some(defect) = landing::hooks_file_defect(&args.target)? {
233 defects.push(defect);
234 }
235 for entry in entries {
236 let Some(bytes) = landing::read_destination(&args.target, entry)? else {
237 let label = if args.target.join(&entry.destination).exists() {
240 format!("{} (carries no release-kit block)", entry.destination)
241 } else {
242 format!("{} (expected and missing)", entry.destination)
243 };
244 missing.push(label);
245 continue;
246 };
247 let action = match entry.kind {
248 Kind::Rendered | Kind::Seeded if bytes == entry.rendered => "matches",
249 Kind::Rendered => {
250 mismatches.push(entry.destination.clone());
251 "differs"
252 }
253 Kind::Seeded => "differs",
254 Kind::State => "state",
255 };
256 files.push(FileEntry {
257 path: entry.destination.clone(),
258 kind: entry.kind.as_str(),
259 action,
260 });
261 records.push(FileRecord {
262 destination: entry.destination.clone(),
263 kind: entry.kind,
264 sha256: Digest::of(&bytes),
265 baseline_sha256: match entry.kind {
266 Kind::State => None,
267 Kind::Rendered | Kind::Seeded => Some(Digest::of(&entry.baseline)),
268 },
269 });
270 }
271 if mismatches.is_empty() && missing.is_empty() && defects.is_empty() {
272 return Ok((files, records));
273 }
274 let listed: Vec<String> = mismatches
275 .iter()
276 .map(|path| format!("{path} (differs from the rendered candidate)"))
277 .chain(missing.iter().cloned())
278 .chain(defects.iter().cloned())
279 .collect();
280 Err(RkError::refusal(
281 Diagnostic::new(
282 Reason::StateDrift,
283 format!(
284 "this target is not adoptable as-is, and no record was written: {}",
285 listed.join(", ")
286 ),
287 )
288 .expected(format!(
289 "every rendered destination matching the {} candidate, byte for byte",
290 workflow.as_str()
291 ))
292 .action(format!(
293 "align first: rk adopt without --apply lists every differing destination; bring each to the selected candidate's bytes — rk snippet and rk payload print them — then re-run, or select the other candidate with --workflow or --style{}",
294 if mismatches.iter().any(|path| path == "SECURITY.md") {
298 ". 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"
299 } else {
300 ""
301 }
302 ))
303 .target_state("unchanged"),
304 ))
305}
306
307#[cfg(test)]
308mod tests {
309 use super::{FileEntry, Report};
310
311 #[test]
313 fn the_adopt_report_schema_snapshot_holds() {
314 let report = Report {
315 schema: "rk.adopt/5",
316 config: crate::config::Plan {
317 action: "added",
318 changes: vec![],
319 content: "schema_version = 1\n".into(),
320 },
321 mode: "apply",
322 target: "/tmp/t".into(),
323 tech: "rust".into(),
324 forge: "github".into(),
325 repo: "acme/widget".into(),
326 workflow: "branches",
327 style: "trunk",
328 nix: false,
329 withheld: None,
330 files: vec![FileEntry {
331 path: "release-plz.toml".into(),
332 kind: "seeded",
333 action: "differs",
334 }],
335 next: vec!["commit the config and the record".into()],
336 };
337 assert_eq!(
338 serde_json::to_string(&report).expect("a report serializes"),
339 r#"{"schema":"rk.adopt/5","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 record"]}"#
340 );
341 }
342}