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 --target {} --apply writes the record and nothing else",
164 args.target
165 )]
166 };
167 out.next(&next);
168 out.emit(&Report {
169 schema: "rk.adopt/3",
170 mode: if args.apply { "apply" } else { "preview" },
171 target: args.target.to_string(),
172 tech,
173 forge: resolved.forge,
174 repo,
175 workflow: workflow.as_str(),
176 style: style.as_str(),
177 files,
178 next,
179 })
180}
181
182fn verify(
186 args: &AdoptArgs,
187 workflow: Workflow,
188 entries: &[landing::Entry],
189) -> Result<(Vec<FileEntry>, Vec<FileRecord>), RkError> {
190 let mut mismatches: Vec<String> = Vec::new();
191 let mut missing: Vec<String> = Vec::new();
192 let mut files = Vec::new();
193 let mut records = Vec::new();
194 let mut defects: Vec<String> = Vec::new();
197 if let Some(defect) = landing::hooks_file_defect(&args.target)? {
198 defects.push(defect);
199 }
200 for entry in entries {
201 let Some(bytes) = landing::read_destination(&args.target, entry)? else {
202 let label = if args.target.join(&entry.destination).exists() {
205 format!("{} (carries no release-kit block)", entry.destination)
206 } else {
207 format!("{} (expected and missing)", entry.destination)
208 };
209 missing.push(label);
210 continue;
211 };
212 let action = match entry.kind {
213 Kind::Rendered | Kind::Seeded if bytes == entry.rendered => "matches",
214 Kind::Rendered => {
215 mismatches.push(entry.destination.clone());
216 "differs"
217 }
218 Kind::Seeded => "differs",
219 Kind::State => "state",
220 };
221 files.push(FileEntry {
222 path: entry.destination.clone(),
223 kind: entry.kind.as_str(),
224 action,
225 });
226 records.push(FileRecord {
227 destination: entry.destination.clone(),
228 kind: entry.kind,
229 sha256: Digest::of(&bytes),
230 baseline_sha256: match entry.kind {
231 Kind::State => None,
232 Kind::Rendered | Kind::Seeded => Some(Digest::of(&entry.baseline)),
233 },
234 });
235 }
236 if mismatches.is_empty() && missing.is_empty() && defects.is_empty() {
237 return Ok((files, records));
238 }
239 let listed: Vec<String> = mismatches
240 .iter()
241 .map(|path| format!("{path} (differs from the rendered candidate)"))
242 .chain(missing.iter().cloned())
243 .chain(defects.iter().cloned())
244 .collect();
245 Err(RkError::refusal(
246 Diagnostic::new(
247 Reason::StateDrift,
248 format!(
249 "this target is not adoptable as-is, and no record was written: {}",
250 listed.join(", ")
251 ),
252 )
253 .expected(format!(
254 "every rendered destination matching the {} candidate, byte for byte",
255 workflow.as_str()
256 ))
257 .action(
258 "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",
259 )
260 .target_state("unchanged"),
261 ))
262}
263
264fn resolved_tech(args: &AdoptArgs) -> Result<String, RkError> {
267 args.tech.as_deref().map_or_else(
268 || {
269 crate::detect::tech_of(args.target.as_std_path())
270 .map(str::to_owned)
271 .ok_or_else(|| {
272 RkError::missing(
273 Diagnostic::new(
274 Reason::TargetNotFound,
275 "no technology detected: the target has no version file",
276 )
277 .expected("a Cargo.toml, pyproject.toml, or VERSION file")
278 .action("pass --tech <rust|python|bash>"),
279 )
280 })
281 },
282 |tech| Ok(tech.to_owned()),
283 )
284}
285
286fn required_scopes(raw: Option<&str>) -> Result<Vec<String>, RkError> {
289 landing::parse_scopes(raw.ok_or_else(|| {
290 RkError::Usage(
291 "an adoption renders the candidate under the scopes parameter; pass --scopes <list>, the Conventional Commit scopes this project accepts".into(),
292 )
293 })?)
294}
295
296#[cfg(test)]
297mod tests {
298 #![allow(clippy::expect_used)]
299
300 use super::{FileEntry, Report};
301
302 #[test]
304 fn the_adopt_report_schema_snapshot_holds() {
305 let report = Report {
306 schema: "rk.adopt/3",
307 mode: "apply",
308 target: "/tmp/t".into(),
309 tech: "rust".into(),
310 forge: "github".into(),
311 repo: "acme/widget".into(),
312 workflow: "branches",
313 style: "trunk",
314 files: vec![FileEntry {
315 path: "release-plz.toml".into(),
316 kind: "seeded",
317 action: "differs",
318 }],
319 next: vec!["commit the record".into()],
320 };
321 assert_eq!(
322 serde_json::to_string(&report).expect("a report serializes"),
323 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"]}"#
324 );
325 }
326}