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 config: crate::config::Plan,
61 files: Vec<FileEntry>,
63 next: Vec<String>,
65}
66
67#[allow(clippy::too_many_lines)]
77pub fn run(args: &AdoptArgs) -> Result<(), RkError> {
78 let out = Output::new(args.json);
79 if !args.target.is_dir() {
80 return Err(RkError::missing(
81 Diagnostic::new(
82 Reason::TargetNotFound,
83 format!("target {} is not a directory", args.target),
84 )
85 .expected("an existing repository to adopt"),
86 ));
87 }
88 if landing::manifest::load(&args.target)?.is_some() {
89 return Err(RkError::refusal(
90 Diagnostic::new(
91 Reason::StateDrift,
92 format!(
93 "{} already carries {}; it needs no adoption",
94 args.target,
95 manifest::MANIFEST_PATH
96 ),
97 )
98 .expected("a target without a landing record")
99 .action(format!(
100 "rk upgrade --target {} takes it to this binary's payload",
101 args.target
102 ))
103 .target_state("unchanged"),
104 ));
105 }
106 let config = crate::config::load(args.target.as_std_path())?;
107 let params = landing::Params::resolve(
108 &args.target,
109 &landing::Inputs {
110 tech: args.tech.as_deref(),
111 forge: args.forge.as_deref(),
112 repo: args.repo.as_deref(),
113 workflow: args.workflow.as_deref().map(Workflow::parse).transpose()?,
114 style: args.style.as_deref().map(Style::parse).transpose()?,
115 nix: args.nix.then_some(true),
116 },
117 config.as_ref(),
118 None,
119 landing::Purpose::Adopt,
120 )?;
121 let config =
122 crate::config::Plan::new(args.target.as_std_path(), ¶ms, config.as_ref(), None)?;
123 let tech = params.tech().to_owned();
124 let repo = params.repo().to_owned();
125 let workflow = params.workflow();
126 let style = params
127 .style()
128 .ok_or_else(|| RkError::Usage("landing style is unresolved".into()))?;
129 let mut entries = landing::projection(¶ms)?;
130 let withheld = landing::withhold_nix(&args.target, params.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 config.apply(args.target.as_std_path())?;
145 manifest::write(
146 &args.target,
147 &Manifest {
148 schema_version: manifest::SCHEMA_VERSION,
149 rk_version: env!("CARGO_PKG_VERSION").to_owned(),
150 payload_sha256: crate::commands::payload::report().payload_sha256,
151 origin: "adopt".to_owned(),
152 tech: tech.clone(),
153 forge: params.forge().to_owned(),
154 landed_at: manifest::now(),
155 parameters: Parameters {
156 repo: repo.clone(),
157 workflow,
158 style: Some(style),
159 nix: params.nix(),
160 trunk: params.trunk().to_owned(),
161 line_prefix: params.line_prefix().to_owned(),
162 },
163 files: records,
164 pins: registry::pins_for(&tech)
165 .into_iter()
166 .map(|pin| (pin.name, pin.version))
167 .collect(),
168 },
169 )?;
170 out.result_line(format!("wrote {}", manifest::MANIFEST_PATH));
171 }
172
173 let next = if args.apply {
174 vec![
175 "commit the config and the record".to_owned(),
176 format!("rk status --target {} reports this landing", args.target),
177 ]
178 } else {
179 vec![format!(
180 "rk adopt --tech {tech} --forge {} --repo {repo} --workflow {} --style {}{} --target {} --apply writes the config and the record inside .release-kit/",
181 params.forge().to_owned(),
182 workflow.as_str(),
183 style.as_str(),
184 if params.nix() { " --nix" } else { "" },
185 args.target
186 )]
187 };
188 out.result_line(format!(
189 "{} {}\n{}",
190 config.action,
191 crate::config::CONFIG_PATH,
192 config.content
193 ));
194 out.next(&next);
195 out.emit(&Report {
196 schema: "rk.adopt/5",
197 config,
198 mode: if args.apply { "apply" } else { "preview" },
199 target: args.target.to_string(),
200 tech,
201 forge: params.forge().to_owned(),
202 repo,
203 workflow: workflow.as_str(),
204 style: style.as_str(),
205 nix: params.nix(),
206 withheld: (!withheld.is_empty()).then_some(withheld),
207 files,
208 next,
209 })
210}
211
212fn verify(
216 args: &AdoptArgs,
217 workflow: Workflow,
218 entries: &[landing::Entry],
219) -> Result<(Vec<FileEntry>, Vec<FileRecord>), RkError> {
220 let mut mismatches: Vec<String> = Vec::new();
221 let mut missing: Vec<String> = Vec::new();
222 let mut files = Vec::new();
223 let mut records = Vec::new();
224 let mut defects: Vec<String> = Vec::new();
227 if let Some(defect) = landing::hooks_file_defect(&args.target)? {
228 defects.push(defect);
229 }
230 for entry in entries {
231 let Some(bytes) = landing::read_destination(&args.target, entry)? else {
232 let label = if args.target.join(&entry.destination).exists() {
235 format!("{} (carries no release-kit block)", entry.destination)
236 } else {
237 format!("{} (expected and missing)", entry.destination)
238 };
239 missing.push(label);
240 continue;
241 };
242 let action = match entry.kind {
243 Kind::Rendered | Kind::Seeded if bytes == entry.rendered => "matches",
244 Kind::Rendered => {
245 mismatches.push(entry.destination.clone());
246 "differs"
247 }
248 Kind::Seeded => "differs",
249 Kind::State => "state",
250 };
251 files.push(FileEntry {
252 path: entry.destination.clone(),
253 kind: entry.kind.as_str(),
254 action,
255 });
256 records.push(FileRecord {
257 destination: entry.destination.clone(),
258 kind: entry.kind,
259 sha256: Digest::of(&bytes),
260 baseline_sha256: match entry.kind {
261 Kind::State => None,
262 Kind::Rendered | Kind::Seeded => Some(Digest::of(&entry.baseline)),
263 },
264 });
265 }
266 if mismatches.is_empty() && missing.is_empty() && defects.is_empty() {
267 return Ok((files, records));
268 }
269 let listed: Vec<String> = mismatches
270 .iter()
271 .map(|path| format!("{path} (differs from the rendered candidate)"))
272 .chain(missing.iter().cloned())
273 .chain(defects.iter().cloned())
274 .collect();
275 Err(RkError::refusal(
276 Diagnostic::new(
277 Reason::StateDrift,
278 format!(
279 "this target is not adoptable as-is, and no record was written: {}",
280 listed.join(", ")
281 ),
282 )
283 .expected(format!(
284 "every rendered destination matching the {} candidate, byte for byte",
285 workflow.as_str()
286 ))
287 .action(
288 "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",
289 )
290 .target_state("unchanged"),
291 ))
292}
293
294#[cfg(test)]
295mod tests {
296 #![allow(clippy::expect_used)]
297
298 use super::{FileEntry, Report};
299
300 #[test]
302 fn the_adopt_report_schema_snapshot_holds() {
303 let report = Report {
304 schema: "rk.adopt/5",
305 config: crate::config::Plan {
306 action: "added",
307 changes: vec![],
308 content: "schema_version = 1\n".into(),
309 },
310 mode: "apply",
311 target: "/tmp/t".into(),
312 tech: "rust".into(),
313 forge: "github".into(),
314 repo: "acme/widget".into(),
315 workflow: "branches",
316 style: "trunk",
317 nix: false,
318 withheld: None,
319 files: vec![FileEntry {
320 path: "release-plz.toml".into(),
321 kind: "seeded",
322 action: "differs",
323 }],
324 next: vec!["commit the config and the record".into()],
325 };
326 assert_eq!(
327 serde_json::to_string(&report).expect("a report serializes"),
328 r#"{"schema":"rk.adopt/5","mode":"apply","target":"/tmp/t","tech":"rust","forge":"github","repo":"acme/widget","workflow":"branches","style":"trunk","nix":false,"config":{"action":"added","changes":[],"content":"schema_version = 1\n"},"files":[{"path":"release-plz.toml","kind":"seeded","action":"differs"}],"next":["commit the config and the record"]}"#
329 );
330 }
331}