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