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 = match args.tech.as_deref() {
97 Some(tech) => tech.to_owned(),
98 None => crate::detect::tech_of(args.target.as_std_path())
99 .ok_or_else(|| {
100 RkError::missing(
101 Diagnostic::new(
102 Reason::TargetNotFound,
103 "no technology detected: the target has no version file",
104 )
105 .expected("a Cargo.toml, pyproject.toml, or VERSION file")
106 .action("pass --tech <rust|python|bash>"),
107 )
108 })?
109 .to_owned(),
110 };
111 let entries = landing::projection(&tech, &resolved.forge, &repo)?;
112 let (files, records) = verify(args, &entries)?;
113
114 for file in &files {
115 out.result_line(match file.action {
116 "differs" => format!("differs {} (seeded, target-owned)", file.path),
117 action => format!("{action} {}", file.path),
118 });
119 }
120
121 if args.apply {
122 manifest::write(
123 &args.target,
124 &Manifest {
125 schema_version: manifest::SCHEMA_VERSION,
126 rk_version: env!("CARGO_PKG_VERSION").to_owned(),
127 payload_sha256: crate::commands::payload::report().payload_sha256,
128 origin: "adopt".to_owned(),
129 tech: tech.clone(),
130 forge: resolved.forge.clone(),
131 landed_at: manifest::now(),
132 parameters: Parameters { repo: repo.clone() },
133 files: records,
134 pins: registry::pins_for(&tech)
135 .into_iter()
136 .map(|pin| (pin.name, pin.version))
137 .collect(),
138 },
139 )?;
140 out.result_line(format!("wrote {}", manifest::MANIFEST_PATH));
141 }
142
143 let next = if args.apply {
144 vec![
145 "commit the record".to_owned(),
146 format!("rk status --target {} reports this landing", args.target),
147 ]
148 } else {
149 vec![format!(
150 "rk adopt --target {} --apply writes the record and nothing else",
151 args.target
152 )]
153 };
154 out.next(&next);
155 out.emit(&Report {
156 schema: "rk.adopt/1",
157 mode: if args.apply { "apply" } else { "preview" },
158 target: args.target.to_string(),
159 tech,
160 forge: resolved.forge,
161 repo,
162 files,
163 next,
164 })
165}
166
167fn verify(
171 args: &AdoptArgs,
172 entries: &[landing::Entry],
173) -> Result<(Vec<FileEntry>, Vec<FileRecord>), RkError> {
174 let mut mismatches: Vec<String> = Vec::new();
175 let mut missing: Vec<String> = Vec::new();
176 let mut files = Vec::new();
177 let mut records = Vec::new();
178 for entry in entries {
179 let Some(bytes) = landing::read_destination(&args.target, entry)? else {
180 let label = if args.target.join(&entry.destination).exists() {
183 format!("{} (carries no release-kit block)", entry.destination)
184 } else {
185 format!("{} (expected and missing)", entry.destination)
186 };
187 missing.push(label);
188 continue;
189 };
190 let action = match entry.kind {
191 Kind::Rendered | Kind::Seeded if bytes == entry.rendered => "matches",
192 Kind::Rendered => {
193 mismatches.push(entry.destination.clone());
194 "differs"
195 }
196 Kind::Seeded => "differs",
197 Kind::State => "state",
198 };
199 files.push(FileEntry {
200 path: entry.destination.clone(),
201 kind: entry.kind.as_str(),
202 action,
203 });
204 records.push(FileRecord {
205 destination: entry.destination.clone(),
206 kind: entry.kind,
207 sha256: Digest::of(&bytes),
208 baseline_sha256: match entry.kind {
209 Kind::State => None,
210 Kind::Rendered | Kind::Seeded => Some(Digest::of(&entry.baseline)),
211 },
212 });
213 }
214 if mismatches.is_empty() && missing.is_empty() {
215 return Ok((files, records));
216 }
217 let listed: Vec<String> = mismatches
218 .iter()
219 .map(|path| format!("{path} (differs from the rendered candidate)"))
220 .chain(missing.iter().cloned())
221 .collect();
222 Err(RkError::refusal(
223 Diagnostic::new(
224 Reason::StateDrift,
225 format!(
226 "this target is not adoptable as-is, and no record was written: {}",
227 listed.join(", ")
228 ),
229 )
230 .expected("every rendered destination matching this payload's candidate, byte for byte")
231 .action(
232 "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",
233 )
234 .target_state("unchanged"),
235 ))
236}
237
238#[cfg(test)]
239mod tests {
240 #![allow(clippy::expect_used)]
241
242 use super::{FileEntry, Report};
243
244 #[test]
246 fn the_adopt_report_schema_snapshot_holds() {
247 let report = Report {
248 schema: "rk.adopt/1",
249 mode: "apply",
250 target: "/tmp/t".into(),
251 tech: "rust".into(),
252 forge: "github".into(),
253 repo: "acme/widget".into(),
254 files: vec![FileEntry {
255 path: "release-plz.toml".into(),
256 kind: "seeded",
257 action: "differs",
258 }],
259 next: vec!["commit the record".into()],
260 };
261 assert_eq!(
262 serde_json::to_string(&report).expect("a report serializes"),
263 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"]}"#
264 );
265 }
266}