1use serde::Serialize;
15
16use crate::cli::adopt::AdoptArgs;
17use crate::commands::reconcile::{self, FrontApplied, Trace};
18use crate::diagnostic::{Diagnostic, Reason};
19use crate::error::RkError;
20use crate::landing::manifest::{self, Style, Workflow};
21use crate::landing::{self, Kind};
22use crate::output::Output;
23use crate::plan::gather::Flags;
24use crate::plan::{Disposition, Intent, PlanRequest, Planned};
25use crate::release::EmbeddedReleaseSource;
26
27#[derive(Debug, Serialize)]
29struct FileEntry {
30 path: String,
32 kind: &'static str,
34 action: &'static str,
36}
37
38#[derive(Debug, Serialize)]
40struct Report {
41 schema: &'static str,
43 mode: &'static str,
45 target: String,
47 tech: String,
49 forge: String,
51 repo: String,
53 workflow: &'static str,
56 style: &'static str,
57 nix: bool,
59 #[serde(skip_serializing_if = "Option::is_none")]
62 withheld: Option<Vec<landing::Withheld>>,
63 config: crate::config::Plan,
64 files: Vec<FileEntry>,
66 #[serde(skip_serializing_if = "Option::is_none")]
68 plan: Option<Trace>,
69 next: Vec<String>,
71}
72
73#[allow(
83 clippy::too_many_lines,
84 reason = "one adopt run is one linear sequence of checks against one target, and cutting it would separate a refusal from the order it is reported in"
85)]
86pub fn run(args: &AdoptArgs) -> Result<(), RkError> {
87 let out = Output::new(args.json);
88 if !args.target.is_dir() {
89 return Err(RkError::missing(
90 Diagnostic::new(
91 Reason::TargetNotFound,
92 format!("target {} is not a directory", args.target),
93 )
94 .expected("an existing repository to adopt"),
95 ));
96 }
97 if landing::manifest::load(&args.target)?.is_some() {
98 return Err(RkError::refusal(
99 Diagnostic::new(
100 Reason::StateDrift,
101 format!(
102 "{} already carries {}; it needs no adoption",
103 args.target,
104 manifest::MANIFEST_PATH
105 ),
106 )
107 .expected("a target without a landing record")
108 .action(format!(
109 "rk upgrade --target {} takes it to this binary's payload",
110 args.target
111 ))
112 .target_state("unchanged"),
113 ));
114 }
115 let config = crate::config::load(args.target.as_std_path())?;
116 let source = EmbeddedReleaseSource;
117 let params = landing::Params::resolve(
118 &source,
119 &args.target,
120 &landing::Inputs {
121 tech: args.tech.as_deref(),
122 forge: args.forge.as_deref(),
123 repo: args.repo.as_deref(),
124 workflow: args.workflow.as_deref().map(Workflow::parse).transpose()?,
125 style: args.style.as_deref().map(Style::parse).transpose()?,
126 nix: args.nix.then_some(true),
127 },
128 config.as_ref(),
129 None,
130 landing::Purpose::Adopt,
131 )?;
132 let tech = params.tech().to_owned();
133 let repo = params.repo().to_owned();
134 let workflow = params.workflow();
135 let style = params
136 .style()
137 .ok_or_else(|| RkError::Usage("landing style is unresolved".into()))?;
138 let request = PlanRequest {
139 target: args.target.clone(),
140 intent: Intent::Adopt,
141 selector: "embedded".into(),
142 fetch: false,
143 observe_forge: false,
144 flags: Flags {
145 tech: Some(tech.clone()),
146 forge: Some(params.forge().to_owned()),
147 repo: Some(repo.clone()),
148 workflow: Some(workflow.as_str().to_owned()),
149 style: Some(style.as_str().to_owned()),
150 nix: Some(params.nix()),
151 },
152 decisions: std::collections::BTreeMap::new(),
153 }
154 .canonicalized()?;
155 let planned = reconcile::compute(&request, &manifest::now())?;
156 let config = planned
157 .config
158 .clone()
159 .ok_or_else(|| RkError::Usage("landing parameters are unresolved".into()))?;
160 let files = verify(args, workflow, &planned)?;
161
162 for file in &files {
163 out.result_line(match file.action {
164 "differs" => format!("differs {} (seeded, target-owned)", file.path),
165 action => format!("{action} {}", file.path),
166 });
167 }
168 for entry in &planned.withheld {
169 out.result_line(format!("withheld {}: {}", entry.path, entry.reason));
170 }
171
172 let applied = if args.apply {
173 let applied = reconcile::apply_in_process(&planned, &request, "adopt")?;
174 out.result_line(format!("wrote {}", manifest::MANIFEST_PATH));
175 out.result_line(applied.line());
176 Some(applied)
177 } else {
178 None
179 };
180
181 let next = if args.apply {
182 vec![
183 "commit the config and the record".to_owned(),
184 format!("rk status --target {} reports this landing", args.target),
185 ]
186 } else {
187 vec![format!(
188 "rk adopt --tech {tech} --forge {} --repo {repo} --workflow {} --style {}{} --target {} --apply writes the config and the record inside .release-kit/",
189 params.forge().to_owned(),
190 workflow.as_str(),
191 style.as_str(),
192 if params.nix() { " --nix" } else { "" },
193 args.target
194 )]
195 };
196 out.result_line(format!(
197 "{} {}\n{}",
198 config.action,
199 crate::config::CONFIG_PATH,
200 config.content
201 ));
202 out.next(&next);
203 out.emit(&Report {
204 schema: "rk.adopt/6",
205 config,
206 mode: if args.apply { "apply" } else { "preview" },
207 target: args.target.to_string(),
208 tech,
209 forge: params.forge().to_owned(),
210 repo,
211 workflow: workflow.as_str(),
212 style: style.as_str(),
213 nix: params.nix(),
214 withheld: (!planned.withheld.is_empty()).then(|| planned.withheld.clone()),
215 files,
216 plan: applied.as_ref().map(FrontApplied::trace),
217 next,
218 })?;
219 applied
220 .and_then(|applied| applied.applied.failure())
221 .map_or(Ok(()), Err)
222}
223
224fn verify(
228 args: &AdoptArgs,
229 workflow: Workflow,
230 planned: &Planned,
231) -> Result<Vec<FileEntry>, RkError> {
232 let mut mismatches: Vec<String> = Vec::new();
233 let mut missing: Vec<String> = Vec::new();
234 let mut files = Vec::new();
235 let defects: Vec<String> = planned
238 .plan
239 .preconditions
240 .iter()
241 .filter(|p| p.id == "hooks-file-spliceable")
242 .filter_map(|p| match &p.evaluation {
243 crate::plan::Evaluation::Unsatisfied { reason } => Some(reason.clone()),
244 _ => None,
245 })
246 .collect();
247 for outcome in &planned.outcomes {
248 if outcome.disposition == Disposition::Write {
249 let label = if args.target.join(&outcome.path).exists() {
252 format!("{} (carries no release-kit block)", outcome.path)
253 } else {
254 format!("{} (expected and missing)", outcome.path)
255 };
256 missing.push(label);
257 continue;
258 }
259 let action = match (outcome.kind, outcome.disposition) {
260 (Kind::Rendered | Kind::Seeded, Disposition::Unchanged) => "matches",
261 (Kind::Rendered, _) => {
262 mismatches.push(outcome.path.clone());
263 "differs"
264 }
265 (Kind::Seeded, _) => "differs",
266 (Kind::State, _) => "state",
267 };
268 files.push(FileEntry {
269 path: outcome.path.clone(),
270 kind: outcome.kind.as_str(),
271 action,
272 });
273 }
274 if mismatches.is_empty() && missing.is_empty() && defects.is_empty() {
275 return Ok(files);
276 }
277 let listed: Vec<String> = mismatches
278 .iter()
279 .map(|path| format!("{path} (differs from the rendered candidate)"))
280 .chain(missing.iter().cloned())
281 .chain(defects.iter().cloned())
282 .collect();
283 Err(RkError::refusal(
284 Diagnostic::new(
285 Reason::StateDrift,
286 format!(
287 "this target is not adoptable as-is, and no record was written: {}",
288 listed.join(", ")
289 ),
290 )
291 .expected(format!(
292 "every rendered destination matching the {} candidate, byte for byte",
293 workflow.as_str()
294 ))
295 .action(format!(
296 "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{}",
297 if mismatches.iter().any(|path| path == "SECURITY.md") {
301 ". SECURITY.md states two facts a target owns: set security.contact and security.response in .release-kit/config.toml to the wording this policy already carries, and the candidate matches"
302 } else {
303 ""
304 }
305 ))
306 .target_state("unchanged"),
307 ))
308}
309
310#[cfg(test)]
311mod tests {
312 use super::{FileEntry, Report};
313
314 #[test]
316 fn the_adopt_report_schema_snapshot_holds() {
317 let report = Report {
318 schema: "rk.adopt/6",
319 config: crate::config::Plan {
320 action: "added",
321 changes: vec![],
322 content: "schema_version = 1\n".into(),
323 },
324 mode: "apply",
325 target: "/tmp/t".into(),
326 tech: "rust".into(),
327 forge: "github".into(),
328 repo: "acme/widget".into(),
329 workflow: "branches",
330 style: "trunk",
331 nix: false,
332 withheld: None,
333 files: vec![FileEntry {
334 path: "release-plz.toml".into(),
335 kind: "seeded",
336 action: "differs",
337 }],
338 plan: None,
339 next: vec!["commit the config and the record".into()],
340 };
341 assert_eq!(
342 serde_json::to_string(&report).expect("a report serializes"),
343 r#"{"schema":"rk.adopt/6","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"]}"#
344 );
345 }
346}