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 workflow = Workflow::parse(&args.workflow)?;
109 let style = Style::parse(args.style.as_deref().ok_or_else(|| {
113 RkError::Usage(
114 "an adoption verifies against one rendered candidate; pass --style <trunk|lines>, the release style this target runs".into(),
115 )
116 })?)?;
117 let mut entries = landing::projection(
118 &tech,
119 &resolved.forge,
120 &repo,
121 workflow,
122 Some(style),
123 args.nix,
124 )?;
125 let withheld = landing::withhold_nix(&args.target, args.nix, None, &mut entries)?;
129 let (files, records) = verify(args, workflow, &entries)?;
130
131 for file in &files {
132 out.result_line(match file.action {
133 "differs" => format!("differs {} (seeded, target-owned)", file.path),
134 action => format!("{action} {}", file.path),
135 });
136 }
137 for entry in &withheld {
138 out.result_line(format!("withheld {}: {}", entry.path, entry.reason));
139 }
140
141 if args.apply {
142 manifest::write(
143 &args.target,
144 &Manifest {
145 schema_version: manifest::SCHEMA_VERSION,
146 rk_version: env!("CARGO_PKG_VERSION").to_owned(),
147 payload_sha256: crate::commands::payload::report().payload_sha256,
148 origin: "adopt".to_owned(),
149 tech: tech.clone(),
150 forge: resolved.forge.clone(),
151 landed_at: manifest::now(),
152 parameters: Parameters {
153 repo: repo.clone(),
154 workflow,
155 style: Some(style),
156 nix: args.nix,
157 },
158 files: records,
159 pins: registry::pins_for(&tech)
160 .into_iter()
161 .map(|pin| (pin.name, pin.version))
162 .collect(),
163 },
164 )?;
165 out.result_line(format!("wrote {}", manifest::MANIFEST_PATH));
166 }
167
168 let next = if args.apply {
169 vec![
170 "commit the record".to_owned(),
171 format!("rk status --target {} reports this landing", args.target),
172 ]
173 } else {
174 vec![format!(
175 "rk adopt --tech {tech} --forge {} --repo {repo} --workflow {} --style {}{} --target {} --apply writes the record and nothing else",
176 resolved.forge,
177 workflow.as_str(),
178 style.as_str(),
179 if args.nix { " --nix" } else { "" },
180 args.target
181 )]
182 };
183 out.next(&next);
184 out.emit(&Report {
185 schema: "rk.adopt/4",
186 mode: if args.apply { "apply" } else { "preview" },
187 target: args.target.to_string(),
188 tech,
189 forge: resolved.forge,
190 repo,
191 workflow: workflow.as_str(),
192 style: style.as_str(),
193 nix: args.nix,
194 withheld: (!withheld.is_empty()).then_some(withheld),
195 files,
196 next,
197 })
198}
199
200fn verify(
204 args: &AdoptArgs,
205 workflow: Workflow,
206 entries: &[landing::Entry],
207) -> Result<(Vec<FileEntry>, Vec<FileRecord>), RkError> {
208 let mut mismatches: Vec<String> = Vec::new();
209 let mut missing: Vec<String> = Vec::new();
210 let mut files = Vec::new();
211 let mut records = Vec::new();
212 let mut defects: Vec<String> = Vec::new();
215 if let Some(defect) = landing::hooks_file_defect(&args.target)? {
216 defects.push(defect);
217 }
218 for entry in entries {
219 let Some(bytes) = landing::read_destination(&args.target, entry)? else {
220 let label = if args.target.join(&entry.destination).exists() {
223 format!("{} (carries no release-kit block)", entry.destination)
224 } else {
225 format!("{} (expected and missing)", entry.destination)
226 };
227 missing.push(label);
228 continue;
229 };
230 let action = match entry.kind {
231 Kind::Rendered | Kind::Seeded if bytes == entry.rendered => "matches",
232 Kind::Rendered => {
233 mismatches.push(entry.destination.clone());
234 "differs"
235 }
236 Kind::Seeded => "differs",
237 Kind::State => "state",
238 };
239 files.push(FileEntry {
240 path: entry.destination.clone(),
241 kind: entry.kind.as_str(),
242 action,
243 });
244 records.push(FileRecord {
245 destination: entry.destination.clone(),
246 kind: entry.kind,
247 sha256: Digest::of(&bytes),
248 baseline_sha256: match entry.kind {
249 Kind::State => None,
250 Kind::Rendered | Kind::Seeded => Some(Digest::of(&entry.baseline)),
251 },
252 });
253 }
254 if mismatches.is_empty() && missing.is_empty() && defects.is_empty() {
255 return Ok((files, records));
256 }
257 let listed: Vec<String> = mismatches
258 .iter()
259 .map(|path| format!("{path} (differs from the rendered candidate)"))
260 .chain(missing.iter().cloned())
261 .chain(defects.iter().cloned())
262 .collect();
263 Err(RkError::refusal(
264 Diagnostic::new(
265 Reason::StateDrift,
266 format!(
267 "this target is not adoptable as-is, and no record was written: {}",
268 listed.join(", ")
269 ),
270 )
271 .expected(format!(
272 "every rendered destination matching the {} candidate, byte for byte",
273 workflow.as_str()
274 ))
275 .action(
276 "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",
277 )
278 .target_state("unchanged"),
279 ))
280}
281
282fn resolved_tech(args: &AdoptArgs) -> Result<String, RkError> {
285 args.tech.as_deref().map_or_else(
286 || {
287 crate::detect::tech_of(args.target.as_std_path())
288 .map(str::to_owned)
289 .ok_or_else(|| {
290 RkError::missing(
291 Diagnostic::new(
292 Reason::TargetNotFound,
293 "no technology detected: the target has no version file",
294 )
295 .expected("a Cargo.toml, pyproject.toml, or VERSION file")
296 .action("pass --tech <rust|python|bash>"),
297 )
298 })
299 },
300 |tech| Ok(tech.to_owned()),
301 )
302}
303
304#[cfg(test)]
305mod tests {
306 #![allow(clippy::expect_used)]
307
308 use super::{FileEntry, Report};
309
310 #[test]
312 fn the_adopt_report_schema_snapshot_holds() {
313 let report = Report {
314 schema: "rk.adopt/4",
315 mode: "apply",
316 target: "/tmp/t".into(),
317 tech: "rust".into(),
318 forge: "github".into(),
319 repo: "acme/widget".into(),
320 workflow: "branches",
321 style: "trunk",
322 nix: false,
323 withheld: None,
324 files: vec![FileEntry {
325 path: "release-plz.toml".into(),
326 kind: "seeded",
327 action: "differs",
328 }],
329 next: vec!["commit the record".into()],
330 };
331 assert_eq!(
332 serde_json::to_string(&report).expect("a report serializes"),
333 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"]}"#
334 );
335 }
336}