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