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