use camino::Utf8Path;
use serde::Serialize;
use crate::cli::init::InitArgs;
use crate::diagnostic::{Diagnostic, Reason};
use crate::error::RkError;
use crate::landing::manifest::{self, FileRecord, Manifest, Parameters};
use crate::landing::{self, Entry, Kind};
use crate::output::Output;
use crate::{digest::Digest, embedded, registry};
#[derive(Debug, Serialize)]
struct FileEntry {
path: String,
kind: &'static str,
action: &'static str,
}
#[derive(Debug, Serialize)]
struct SentinelEntry {
path: String,
line: usize,
text: String,
}
#[derive(Debug, Serialize)]
struct Report {
schema: &'static str,
mode: &'static str,
tech: String,
forge: String,
target: String,
#[serde(skip_serializing_if = "Option::is_none")]
repo: Option<String>,
files: Vec<FileEntry>,
#[serde(skip_serializing_if = "Option::is_none")]
sentinels: Option<Vec<SentinelEntry>>,
next: Vec<String>,
}
pub fn run(args: &InitArgs) -> Result<(), RkError> {
let out = Output::new(args.json);
if !args.target.is_dir() {
return Err(RkError::refusal(
Diagnostic::new(
Reason::TargetNotFound,
format!(
"target {} is not a directory; nothing was written",
args.target
),
)
.expected("an existing directory to land into")
.target_state("unchanged"),
));
}
let resolved = landing::resolve(&args.target, args.forge.as_deref(), args.repo.as_deref())?;
let forge = resolved.forge;
if args.apply {
let repo = resolved.repo.ok_or_else(landing::repo_unresolved)?;
let scopes = landing::parse_scopes(args.scopes.as_deref().ok_or_else(|| {
RkError::Usage(
"an apply renders the scope-bearing files; pass --scopes <list>, the Conventional Commit scopes this project accepts".into(),
)
})?)?;
let entries = landing::projection(&args.tech, &forge, &repo, &scopes)?;
apply(out, args, &forge, &repo, &scopes, &entries)
} else {
if resolved.repo.is_none() {
out.frame(
"note: no repository detected; an apply derives the owner from --repo <path>",
);
}
let repo = resolved.repo;
let scopes = args
.scopes
.as_deref()
.map(landing::parse_scopes)
.transpose()?
.unwrap_or_default();
let entries = landing::projection(
&args.tech,
&forge,
repo.as_deref().unwrap_or("OWNER"),
&scopes,
)?;
preview(out, args, &forge, repo, &entries)
}
}
fn preview(
out: Output,
args: &InitArgs,
forge: &str,
repo: Option<String>,
entries: &[Entry],
) -> Result<(), RkError> {
let repo_argument = repo.as_deref().unwrap_or("<owner/name>");
let scopes_argument = args.scopes.as_deref().unwrap_or("<scope,scope>");
let next = vec![format!(
"rk init --tech {} --forge {forge} --repo {repo_argument} --scopes {scopes_argument} --target {} --apply",
args.tech, args.target
)];
out.result_line(format!(
"DRY RUN: rk init writes these files into {}; re-run with --apply",
args.target
));
for entry in entries {
out.result_line(&entry.destination);
}
out.next(&next);
out.emit(&Report {
schema: "rk.init/1",
mode: "preview",
tech: args.tech.clone(),
forge: forge.to_owned(),
target: args.target.to_string(),
repo,
files: entries
.iter()
.map(|entry| FileEntry {
path: entry.destination.clone(),
kind: entry.kind.as_str(),
action: "land",
})
.collect(),
sentinels: None,
next,
})
}
fn apply(
out: Output,
args: &InitArgs,
forge: &str,
repo: &str,
scopes: &[String],
entries: &[Entry],
) -> Result<(), RkError> {
refuse_a_recorded_target(args)?;
landing::hooks_splice_refusal(&args.target)?;
let planned = plan(&args.target, entries)?;
let mut file_entries = Vec::new();
let mut records = Vec::new();
let mut sentinels = Vec::new();
for Planned {
entry,
action,
found,
} in planned
{
if action == "write" {
landing::write_destination(&args.target, entry)?;
}
out.result_line(format!(
"{} {}",
match action {
"write" => "wrote",
"kept" => "kept (target-owned)",
_ => "unchanged",
},
entry.destination
));
let landed = match (action, found) {
("kept", Some(bytes)) => bytes,
_ => entry.rendered.clone(),
};
collect_sentinels(&args.target, &entry.destination, &landed, &mut sentinels);
records.push(FileRecord {
destination: entry.destination.clone(),
kind: entry.kind,
sha256: Digest::of(&landed),
baseline_sha256: match entry.kind {
Kind::State => None,
Kind::Rendered | Kind::Seeded => Some(Digest::of(&entry.baseline)),
},
});
file_entries.push(FileEntry {
path: entry.destination.clone(),
kind: entry.kind.as_str(),
action,
});
}
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: "init".to_owned(),
tech: args.tech.clone(),
forge: forge.to_owned(),
landed_at: manifest::now(),
parameters: Parameters {
repo: repo.to_owned(),
scopes: scopes.to_vec(),
},
files: records,
pins: registry::pins_for(&args.tech)
.into_iter()
.map(|pin| (pin.name, pin.version))
.collect(),
},
)?;
out.result_line(format!("wrote {}", manifest::MANIFEST_PATH));
if sentinels.is_empty() {
out.result_line("no sentinels to fill");
} else {
out.result_line("fill these sentinels before the workflow runs:");
for sentinel in &sentinels {
out.result_line(format!(
"{}:{}: {}",
sentinel.path, sentinel.line, sentinel.text
));
}
}
let next = vec![
if sentinels.is_empty() {
"commit the landed files, the record included".to_owned()
} else {
"fill each sentinel above, then commit the landed files, the record included".to_owned()
},
format!("rk status --target {} reports this landing", args.target),
"rk method setup orders what follows".to_owned(),
];
out.next(&next);
out.emit(&Report {
schema: "rk.init/1",
mode: "apply",
tech: args.tech.clone(),
forge: forge.to_owned(),
target: args.target.to_string(),
repo: Some(repo.to_owned()),
files: file_entries,
sentinels: Some(sentinels),
next,
})
}
fn refuse_a_recorded_target(args: &InitArgs) -> Result<(), RkError> {
if landing::manifest::load(&args.target)?.is_none() {
return Ok(());
}
Err(RkError::refusal(
Diagnostic::new(
Reason::StateDrift,
format!(
"{} already carries {}, and nothing was written",
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"),
))
}
struct Planned<'a> {
entry: &'a Entry,
action: &'static str,
found: Option<Vec<u8>>,
}
fn plan<'a>(target: &Utf8Path, entries: &'a [Entry]) -> Result<Vec<Planned<'a>>, RkError> {
let mut conflicts: Vec<&str> = Vec::new();
let mut planned = Vec::new();
for entry in entries {
let found = landing::read_destination(target, entry)?;
let action = match (&found, entry.kind) {
(None, _) => "write",
(Some(bytes), _) if *bytes == entry.rendered => "unchanged",
(Some(_), Kind::Rendered) => {
conflicts.push(entry.destination.as_str());
"conflict"
}
(Some(_), Kind::Seeded | Kind::State) => "kept",
};
planned.push(Planned {
entry,
action,
found,
});
}
if conflicts.is_empty() {
return Ok(planned);
}
Err(RkError::refusal(
Diagnostic::new(
Reason::StateDrift,
format!(
"these files exist with different content, and nothing was written: {}",
conflicts.join(", ")
),
)
.expected("every rendered destination absent, or holding this landing's bytes")
.target_state("unchanged"),
))
}
fn collect_sentinels(
target: &Utf8Path,
destination: &str,
bytes: &[u8],
found: &mut Vec<SentinelEntry>,
) {
let text = String::from_utf8_lossy(bytes);
for (idx, line) in text.lines().enumerate() {
if line.contains(embedded::SENTINEL) {
found.push(SentinelEntry {
path: target.join(destination).to_string(),
line: idx + 1,
text: line.trim().to_owned(),
});
}
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::expect_used)]
use super::{FileEntry, Report, SentinelEntry};
#[test]
fn the_init_report_schema_snapshot_holds() {
let apply = Report {
schema: "rk.init/1",
mode: "apply",
tech: "rust".into(),
forge: "github".into(),
target: "/tmp/t".into(),
repo: Some("acme/widget".into()),
files: vec![FileEntry {
path: "release-plz.toml".into(),
kind: "seeded",
action: "write",
}],
sentinels: Some(vec![SentinelEntry {
path: "/tmp/t/release-plz.toml".into(),
line: 3,
text: "# TODO(release-kit): keep false for a binary-only crate".into(),
}]),
next: vec!["commit the landed files, the record included".into()],
};
assert_eq!(
serde_json::to_string(&apply).expect("a report serializes"),
r##"{"schema":"rk.init/1","mode":"apply","tech":"rust","forge":"github","target":"/tmp/t","repo":"acme/widget","files":[{"path":"release-plz.toml","kind":"seeded","action":"write"}],"sentinels":[{"path":"/tmp/t/release-plz.toml","line":3,"text":"# TODO(release-kit): keep false for a binary-only crate"}],"next":["commit the landed files, the record included"]}"##
);
let preview = Report {
sentinels: None,
repo: None,
mode: "preview",
..apply
};
assert_eq!(
serde_json::to_string(&preview).expect("a report serializes"),
r#"{"schema":"rk.init/1","mode":"preview","tech":"rust","forge":"github","target":"/tmp/t","files":[{"path":"release-plz.toml","kind":"seeded","action":"write"}],"next":["commit the landed files, the record included"]}"#,
"a preview omits the sentinels and unresolved repo rather than serializing null"
);
}
}