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