use serde::Serialize;
use crate::cli::adopt::AdoptArgs;
use crate::diagnostic::{Diagnostic, Reason};
use crate::digest::Digest;
use crate::error::RkError;
use crate::landing::manifest::{self, FileRecord, Manifest, Parameters};
use crate::landing::{self, Kind};
use crate::output::Output;
use crate::registry;
#[derive(Debug, Serialize)]
struct FileEntry {
path: String,
kind: &'static str,
action: &'static str,
}
#[derive(Debug, Serialize)]
struct Report {
schema: &'static str,
mode: &'static str,
target: String,
tech: String,
forge: String,
repo: String,
files: Vec<FileEntry>,
next: Vec<String>,
}
pub fn run(args: &AdoptArgs) -> Result<(), RkError> {
let out = Output::new(args.json);
if !args.target.is_dir() {
return Err(RkError::missing(
Diagnostic::new(
Reason::TargetNotFound,
format!("target {} is not a directory", args.target),
)
.expected("an existing repository to adopt"),
));
}
if landing::manifest::load(&args.target)?.is_some() {
return Err(RkError::refusal(
Diagnostic::new(
Reason::StateDrift,
format!(
"{} already carries {}; it needs no adoption",
args.target,
manifest::MANIFEST_PATH
),
)
.expected("a target without a landing record")
.action(format!(
"rk upgrade --target {} takes it to this binary's payload",
args.target
))
.target_state("unchanged"),
));
}
let resolved = landing::resolve(&args.target, args.forge.as_deref(), args.repo.as_deref())?;
let repo = resolved.repo.ok_or_else(landing::repo_unresolved)?;
let tech = resolved_tech(args)?;
let scopes = required_scopes(args.scopes.as_deref())?;
let entries = landing::projection(&tech, &resolved.forge, &repo, &scopes)?;
let (files, records) = verify(args, &entries)?;
for file in &files {
out.result_line(match file.action {
"differs" => format!("differs {} (seeded, target-owned)", file.path),
action => format!("{action} {}", file.path),
});
}
if args.apply {
manifest::write(
&args.target,
&Manifest {
schema_version: manifest::SCHEMA_VERSION,
rk_version: env!("CARGO_PKG_VERSION").to_owned(),
payload_sha256: crate::commands::payload::report().payload_sha256,
origin: "adopt".to_owned(),
tech: tech.clone(),
forge: resolved.forge.clone(),
landed_at: manifest::now(),
parameters: Parameters {
repo: repo.clone(),
scopes,
},
files: records,
pins: registry::pins_for(&tech)
.into_iter()
.map(|pin| (pin.name, pin.version))
.collect(),
},
)?;
out.result_line(format!("wrote {}", manifest::MANIFEST_PATH));
}
let next = if args.apply {
vec![
"commit the record".to_owned(),
format!("rk status --target {} reports this landing", args.target),
]
} else {
vec![format!(
"rk adopt --target {} --apply writes the record and nothing else",
args.target
)]
};
out.next(&next);
out.emit(&Report {
schema: "rk.adopt/1",
mode: if args.apply { "apply" } else { "preview" },
target: args.target.to_string(),
tech,
forge: resolved.forge,
repo,
files,
next,
})
}
fn verify(
args: &AdoptArgs,
entries: &[landing::Entry],
) -> Result<(Vec<FileEntry>, Vec<FileRecord>), RkError> {
let mut mismatches: Vec<String> = Vec::new();
let mut missing: Vec<String> = Vec::new();
let mut files = Vec::new();
let mut records = Vec::new();
let mut defects: Vec<String> = Vec::new();
if let Some(defect) = landing::hooks_file_defect(&args.target)? {
defects.push(defect);
}
for entry in entries {
let Some(bytes) = landing::read_destination(&args.target, entry)? else {
let label = if args.target.join(&entry.destination).exists() {
format!("{} (carries no release-kit block)", entry.destination)
} else {
format!("{} (expected and missing)", entry.destination)
};
missing.push(label);
continue;
};
let action = match entry.kind {
Kind::Rendered | Kind::Seeded if bytes == entry.rendered => "matches",
Kind::Rendered => {
mismatches.push(entry.destination.clone());
"differs"
}
Kind::Seeded => "differs",
Kind::State => "state",
};
files.push(FileEntry {
path: entry.destination.clone(),
kind: entry.kind.as_str(),
action,
});
records.push(FileRecord {
destination: entry.destination.clone(),
kind: entry.kind,
sha256: Digest::of(&bytes),
baseline_sha256: match entry.kind {
Kind::State => None,
Kind::Rendered | Kind::Seeded => Some(Digest::of(&entry.baseline)),
},
});
}
if mismatches.is_empty() && missing.is_empty() && defects.is_empty() {
return Ok((files, records));
}
let listed: Vec<String> = mismatches
.iter()
.map(|path| format!("{path} (differs from the rendered candidate)"))
.chain(missing.iter().cloned())
.chain(defects.iter().cloned())
.collect();
Err(RkError::refusal(
Diagnostic::new(
Reason::StateDrift,
format!(
"this target is not adoptable as-is, and no record was written: {}",
listed.join(", ")
),
)
.expected("every rendered destination matching this payload's candidate, byte for byte")
.action(
"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",
)
.target_state("unchanged"),
))
}
fn resolved_tech(args: &AdoptArgs) -> Result<String, RkError> {
args.tech.as_deref().map_or_else(
|| {
crate::detect::tech_of(args.target.as_std_path())
.map(str::to_owned)
.ok_or_else(|| {
RkError::missing(
Diagnostic::new(
Reason::TargetNotFound,
"no technology detected: the target has no version file",
)
.expected("a Cargo.toml, pyproject.toml, or VERSION file")
.action("pass --tech <rust|python|bash>"),
)
})
},
|tech| Ok(tech.to_owned()),
)
}
fn required_scopes(raw: Option<&str>) -> Result<Vec<String>, RkError> {
landing::parse_scopes(raw.ok_or_else(|| {
RkError::Usage(
"an adoption renders the candidate under the scopes parameter; pass --scopes <list>, the Conventional Commit scopes this project accepts".into(),
)
})?)
}
#[cfg(test)]
mod tests {
#![allow(clippy::expect_used)]
use super::{FileEntry, Report};
#[test]
fn the_adopt_report_schema_snapshot_holds() {
let report = Report {
schema: "rk.adopt/1",
mode: "apply",
target: "/tmp/t".into(),
tech: "rust".into(),
forge: "github".into(),
repo: "acme/widget".into(),
files: vec![FileEntry {
path: "release-plz.toml".into(),
kind: "seeded",
action: "differs",
}],
next: vec!["commit the record".into()],
};
assert_eq!(
serde_json::to_string(&report).expect("a report serializes"),
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"]}"#
);
}
}