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 files: Vec<FileEntry>,
56 next: Vec<String>,
58}
59
60#[allow(clippy::too_many_lines)]
70pub fn run(args: &AdoptArgs) -> Result<(), RkError> {
71 let out = Output::new(args.json);
72 if !args.target.is_dir() {
73 return Err(RkError::missing(
74 Diagnostic::new(
75 Reason::TargetNotFound,
76 format!("target {} is not a directory", args.target),
77 )
78 .expected("an existing repository to adopt"),
79 ));
80 }
81 if landing::manifest::load(&args.target)?.is_some() {
82 return Err(RkError::refusal(
83 Diagnostic::new(
84 Reason::StateDrift,
85 format!(
86 "{} already carries {}; it needs no adoption",
87 args.target,
88 manifest::MANIFEST_PATH
89 ),
90 )
91 .expected("a target without a landing record")
92 .action(format!(
93 "rk upgrade --target {} takes it to this binary's payload",
94 args.target
95 ))
96 .target_state("unchanged"),
97 ));
98 }
99 let resolved = landing::resolve(&args.target, args.forge.as_deref(), args.repo.as_deref())?;
100 let repo = resolved.repo.ok_or_else(landing::repo_unresolved)?;
101 let tech = resolved_tech(args)?;
102 let scopes = required_scopes(args.scopes.as_deref())?;
103 let workflow = Workflow::parse(&args.workflow)?;
104 let style = Style::parse(args.style.as_deref().ok_or_else(|| {
108 RkError::Usage(
109 "an adoption verifies against one rendered candidate; pass --style <trunk|lines>, the release style this target runs".into(),
110 )
111 })?)?;
112 let entries = landing::projection(
113 &tech,
114 &resolved.forge,
115 &repo,
116 &scopes,
117 workflow,
118 Some(style),
119 )?;
120 let (files, records) = verify(args, workflow, &entries)?;
121
122 for file in &files {
123 out.result_line(match file.action {
124 "differs" => format!("differs {} (seeded, target-owned)", file.path),
125 action => format!("{action} {}", file.path),
126 });
127 }
128
129 if args.apply {
130 manifest::write(
131 &args.target,
132 &Manifest {
133 schema_version: manifest::SCHEMA_VERSION,
134 rk_version: env!("CARGO_PKG_VERSION").to_owned(),
135 payload_sha256: crate::commands::payload::report().payload_sha256,
136 origin: "adopt".to_owned(),
137 tech: tech.clone(),
138 forge: resolved.forge.clone(),
139 landed_at: manifest::now(),
140 parameters: Parameters {
141 repo: repo.clone(),
142 scopes,
143 workflow,
144 style: Some(style),
145 },
146 files: records,
147 pins: registry::pins_for(&tech)
148 .into_iter()
149 .map(|pin| (pin.name, pin.version))
150 .collect(),
151 },
152 )?;
153 out.result_line(format!("wrote {}", manifest::MANIFEST_PATH));
154 }
155
156 let next = if args.apply {
157 vec![
158 "commit the record".to_owned(),
159 format!("rk status --target {} reports this landing", args.target),
160 ]
161 } else {
162 vec![format!(
163 "rk adopt --tech {tech} --forge {} --repo {repo} --scopes {} --workflow {} --style {} --target {} --apply writes the record and nothing else",
164 resolved.forge,
165 args.scopes.as_deref().unwrap_or("<scope,scope>"),
166 workflow.as_str(),
167 style.as_str(),
168 args.target
169 )]
170 };
171 out.next(&next);
172 out.emit(&Report {
173 schema: "rk.adopt/3",
174 mode: if args.apply { "apply" } else { "preview" },
175 target: args.target.to_string(),
176 tech,
177 forge: resolved.forge,
178 repo,
179 workflow: workflow.as_str(),
180 style: style.as_str(),
181 files,
182 next,
183 })
184}
185
186fn verify(
190 args: &AdoptArgs,
191 workflow: Workflow,
192 entries: &[landing::Entry],
193) -> Result<(Vec<FileEntry>, Vec<FileRecord>), RkError> {
194 let mut mismatches: Vec<String> = Vec::new();
195 let mut missing: Vec<String> = Vec::new();
196 let mut files = Vec::new();
197 let mut records = Vec::new();
198 let mut defects: Vec<String> = Vec::new();
201 if let Some(defect) = landing::hooks_file_defect(&args.target)? {
202 defects.push(defect);
203 }
204 for entry in entries {
205 let Some(bytes) = landing::read_destination(&args.target, entry)? else {
206 let label = if args.target.join(&entry.destination).exists() {
209 format!("{} (carries no release-kit block)", entry.destination)
210 } else {
211 format!("{} (expected and missing)", entry.destination)
212 };
213 missing.push(label);
214 continue;
215 };
216 let action = match entry.kind {
217 Kind::Rendered | Kind::Seeded if bytes == entry.rendered => "matches",
218 Kind::Rendered => {
219 mismatches.push(entry.destination.clone());
220 "differs"
221 }
222 Kind::Seeded => "differs",
223 Kind::State => "state",
224 };
225 files.push(FileEntry {
226 path: entry.destination.clone(),
227 kind: entry.kind.as_str(),
228 action,
229 });
230 records.push(FileRecord {
231 destination: entry.destination.clone(),
232 kind: entry.kind,
233 sha256: Digest::of(&bytes),
234 baseline_sha256: match entry.kind {
235 Kind::State => None,
236 Kind::Rendered | Kind::Seeded => Some(Digest::of(&entry.baseline)),
237 },
238 });
239 }
240 if mismatches.is_empty() && missing.is_empty() && defects.is_empty() {
241 return Ok((files, records));
242 }
243 let listed: Vec<String> = mismatches
244 .iter()
245 .map(|path| format!("{path} (differs from the rendered candidate)"))
246 .chain(missing.iter().cloned())
247 .chain(defects.iter().cloned())
248 .collect();
249 Err(RkError::refusal(
250 Diagnostic::new(
251 Reason::StateDrift,
252 format!(
253 "this target is not adoptable as-is, and no record was written: {}",
254 listed.join(", ")
255 ),
256 )
257 .expected(format!(
258 "every rendered destination matching the {} candidate, byte for byte",
259 workflow.as_str()
260 ))
261 .action(
262 "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",
263 )
264 .target_state("unchanged"),
265 ))
266}
267
268fn resolved_tech(args: &AdoptArgs) -> Result<String, RkError> {
271 args.tech.as_deref().map_or_else(
272 || {
273 crate::detect::tech_of(args.target.as_std_path())
274 .map(str::to_owned)
275 .ok_or_else(|| {
276 RkError::missing(
277 Diagnostic::new(
278 Reason::TargetNotFound,
279 "no technology detected: the target has no version file",
280 )
281 .expected("a Cargo.toml, pyproject.toml, or VERSION file")
282 .action("pass --tech <rust|python|bash>"),
283 )
284 })
285 },
286 |tech| Ok(tech.to_owned()),
287 )
288}
289
290fn required_scopes(raw: Option<&str>) -> Result<Vec<String>, RkError> {
293 landing::parse_scopes(raw.ok_or_else(|| {
294 RkError::Usage(
295 "an adoption renders the candidate under the scopes parameter; pass --scopes <list>, the Conventional Commit scopes this project accepts".into(),
296 )
297 })?)
298}
299
300#[cfg(test)]
301mod tests {
302 #![allow(clippy::expect_used)]
303
304 use super::{FileEntry, Report};
305
306 #[test]
308 fn the_adopt_report_schema_snapshot_holds() {
309 let report = Report {
310 schema: "rk.adopt/3",
311 mode: "apply",
312 target: "/tmp/t".into(),
313 tech: "rust".into(),
314 forge: "github".into(),
315 repo: "acme/widget".into(),
316 workflow: "branches",
317 style: "trunk",
318 files: vec![FileEntry {
319 path: "release-plz.toml".into(),
320 kind: "seeded",
321 action: "differs",
322 }],
323 next: vec!["commit the record".into()],
324 };
325 assert_eq!(
326 serde_json::to_string(&report).expect("a report serializes"),
327 r#"{"schema":"rk.adopt/3","mode":"apply","target":"/tmp/t","tech":"rust","forge":"github","repo":"acme/widget","workflow":"branches","style":"trunk","files":[{"path":"release-plz.toml","kind":"seeded","action":"differs"}],"next":["commit the record"]}"#
328 );
329 }
330}