use std::io::{Read, Write as _};
use std::path::Path;
use std::path::PathBuf;
use crate::error::{RailError, RailResult};
use crate::utils::toolchain_fingerprint;
use crate::workspace::WorkspaceContext;
mod cases;
pub(crate) use cases::run_plan_cases;
#[derive(Debug)]
pub struct PlanOptions {
pub(crate) comparison: PlanComparison,
pub json: bool,
pub explain: bool,
pub explain_work: Option<String>,
pub all: bool,
pub evidence: Vec<PathBuf>,
}
#[derive(Debug, Clone)]
pub(crate) enum PlanComparison {
DefaultMergeBase,
Since(String),
Objects { from: String, to: String },
}
#[derive(Debug)]
enum ResolvedComparison {
Worktree { base: String },
Objects { from: String, to: String },
}
impl PlanComparison {
pub(crate) fn from_cli(since: &Option<String>, from: &Option<String>, to: &Option<String>) -> RailResult<Self> {
match (from, to) {
(Some(from), Some(to)) => {
if since.is_some() {
return Err(RailError::message("--from/--to cannot be combined with --since"));
}
return Ok(Self::Objects {
from: from.clone(),
to: to.clone(),
});
}
(Some(_), None) => return Err(RailError::message("--from requires --to")),
(None, Some(_)) => return Err(RailError::message("--to requires --from")),
(None, None) => {}
}
Ok(since
.as_ref()
.map_or(Self::DefaultMergeBase, |since| Self::Since(since.clone())))
}
fn resolve(self, ctx: &WorkspaceContext, all: bool) -> RailResult<ResolvedComparison> {
match self {
Self::Objects { from, to } => Ok(ResolvedComparison::Objects { from, to }),
Self::Since(base) => Ok(ResolvedComparison::Worktree { base }),
Self::DefaultMergeBase if all => Ok(ResolvedComparison::Worktree { base: "HEAD".into() }),
Self::DefaultMergeBase => {
let git = ctx.git()?.git();
let default_branch = crate::git::detect_default_base_ref(git)?;
Ok(ResolvedComparison::Worktree {
base: git.get_merge_base(&default_branch, "HEAD")?,
})
}
}
}
pub(crate) fn resolve_objects_before_context(
&self,
workspace_root: &std::path::Path,
) -> RailResult<Option<(String, String)>> {
let Self::Objects { from, to } = self else {
return Ok(None);
};
let git = crate::git::SystemGit::open(workspace_root)?;
let from = git.resolve_reference(&format!("{from}^{{commit}}"))?;
let to = git.resolve_reference(&format!("{to}^{{commit}}"))?;
Ok(Some((from, to)))
}
pub(crate) fn replace_objects(&mut self, from: String, to: String) {
*self = Self::Objects { from, to };
}
}
impl ResolvedComparison {
fn base(&self) -> &str {
match self {
Self::Worktree { base } | Self::Objects { from: base, .. } => base,
}
}
fn object_head(&self) -> Option<&str> {
match self {
Self::Objects { to, .. } => Some(to),
Self::Worktree { .. } => None,
}
}
}
pub fn print_plan_schema() {
print!("{}", include_str!("../../schemas/plan-v9.schema.json"));
}
pub(crate) fn verify_saved_plan(
workspace_root: &Path,
config_override: Option<&Path>,
plan_file: &Path,
) -> RailResult<()> {
const MAX_PLAN_BYTES: u64 = 64 * 1024 * 1024;
let (bytes, subject) = if plan_file == Path::new("-") {
(
read_saved_plan(std::io::stdin().lock(), MAX_PLAN_BYTES, 0, "standard input")?,
"standard input".to_string(),
)
} else {
let metadata = std::fs::metadata(plan_file).map_err(|error| {
RailError::message(format!(
"failed to inspect saved plan '{}': {error}",
plan_file.display()
))
})?;
if !metadata.is_file() || metadata.len() > MAX_PLAN_BYTES {
return Err(RailError::message(format!(
"saved plan '{}' must be a regular file no larger than {MAX_PLAN_BYTES} bytes",
plan_file.display()
)));
}
let file = std::fs::File::open(plan_file).map_err(|error| {
RailError::message(format!("failed to read saved plan '{}': {error}", plan_file.display()))
})?;
let capacity = usize::try_from(metadata.len())
.map_err(|_| RailError::message("saved plan size cannot be represented on this host"))?;
(
read_saved_plan(
file,
MAX_PLAN_BYTES,
capacity,
&format!("saved plan '{}'", plan_file.display()),
)?,
format!("saved plan '{}'", plan_file.display()),
)
};
let saved: crate::planning::WorkPlan = serde_json::from_slice(&bytes)
.map_err(|error| RailError::message(format!("failed to parse {subject}: {error}")))?;
let context = WorkspaceContext::build_with_planning_verification_and_config(workspace_root, config_override)?;
let configuration = context.planning_cargo_configuration_identity()?;
verify_saved_binding("Cargo configuration", &saved.inputs.configuration, &configuration)?;
let toolchain = planning_toolchain_identity(&context)?;
verify_saved_binding("toolchain", &saved.inputs.toolchain, &toolchain)?;
verify_saved_binding(
"target",
&saved.inputs.target,
&planning_target_identity(&configuration, &toolchain),
)?;
verify_saved_binding("platform", &saved.inputs.platform, &crate::planning::host_platform())?;
crate::planning::validate_saved_work_plan(&context, &saved)?;
let current_head = context
.planning_head_commit()
.ok_or_else(|| RailError::message("saved-plan verification requires Git planning capture"))?;
verify_saved_binding("head commit", &saved.inputs.head_commit, current_head)?;
if saved.inputs.head == "WORKTREE" {
let expected = saved
.inputs
.capture
.as_deref()
.ok_or_else(|| RailError::message("saved worktree plan has no captured source authority"))?;
let current = context
.planning_snapshot_id()
.ok_or_else(|| RailError::message("current worktree has no captured source authority"))?;
verify_saved_binding("worktree capture", expected, ¤t)?;
} else {
verify_saved_binding("object head", &saved.inputs.head_commit, &saved.inputs.head)?;
let capture = context
.planning_source_capture()
.ok_or_else(|| RailError::message("saved object plan verification requires sparse source capture"))?;
let changes = capture.changes_from(context.git()?.git(), "HEAD")?;
if !changes.entries().is_empty() {
let paths = changes
.entries()
.iter()
.take(8)
.map(|change| change.path.as_str())
.collect::<Vec<_>>()
.join(", ");
return Err(saved_checkout_drift(format!(
"object-bound execution checkout has non-generated source drift: {paths}"
)));
}
}
Ok(())
}
fn read_saved_plan(mut reader: impl Read, maximum: u64, capacity: usize, subject: &str) -> RailResult<Vec<u8>> {
let mut bytes = Vec::with_capacity(capacity);
reader
.by_ref()
.take(maximum + 1)
.read_to_end(&mut bytes)
.map_err(|error| RailError::message(format!("failed to read {subject}: {error}")))?;
if bytes.len() as u64 > maximum {
return Err(RailError::message(format!(
"{subject} must be no larger than {maximum} bytes"
)));
}
Ok(bytes)
}
fn verify_saved_binding(name: &str, expected: &str, current: &str) -> RailResult<()> {
if expected == current {
Ok(())
} else {
Err(saved_checkout_drift(format!(
"saved {name} '{expected}' does not match current authority '{current}'"
)))
}
}
fn saved_checkout_drift(message: String) -> RailError {
RailError::with_help(
message,
"discard the saved plan, stop concurrent workspace changes, and create a new plan before execution",
)
}
pub fn run_plan(ctx: &WorkspaceContext, opts: PlanOptions) -> RailResult<()> {
let plan = build_work_plan(ctx, &opts)?;
ctx.validate_planning_source_unchanged()?;
let rendered = if opts.json {
serde_json::to_string_pretty(&plan)
.map_err(|error| RailError::message(format!("JSON serialization failed: {error}")))?
} else {
crate::planning::format_work_plan(
&plan,
opts.explain,
opts.explain_work.as_deref(),
crate::output::is_verbose(),
)?
};
let mut stdout = std::io::BufWriter::new(std::io::stdout().lock());
stdout.write_all(rendered.as_bytes())?;
if !rendered.ends_with('\n') {
stdout.write_all(b"\n")?;
}
stdout.flush()?;
Ok(())
}
pub fn run_plan_evidence(
ctx: &WorkspaceContext,
work: &str,
output: &std::path::Path,
cargo_args: &[String],
json: bool,
) -> RailResult<()> {
let started = std::time::Instant::now();
let cargo_configuration_identity = ctx.planning_cargo_configuration_identity()?;
let toolchain_identity = planning_toolchain_identity(ctx)?;
let summary = crate::planning::record_planning_evidence(
ctx,
work,
output,
cargo_args,
crate::planning::RecordBindings {
target_identity: planning_target_identity(&cargo_configuration_identity, &toolchain_identity),
cargo_configuration_identity,
toolchain_identity,
},
)?;
let elapsed = started.elapsed();
let mut stdout = std::io::stdout().lock();
if json {
let mut value = serde_json::to_value(&summary)?;
value["elapsed_ms"] = serde_json::Value::from(u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX));
writeln!(stdout, "{}", serde_json::to_string_pretty(&value)?)?;
return Ok(());
}
writeln!(
stdout,
"Recorded {} evidence for {} in {} ({} bytes, {:.1}s)",
summary.work,
summary.source_base.get(..12).unwrap_or(&summary.source_base),
summary.output.display(),
summary.bytes,
elapsed.as_secs_f64()
)?;
writeln!(
stdout,
" {} compiled unit(s), {} file input(s), {} directory input(s)",
summary.units, summary.inputs, summary.directories
)?;
if summary.complete {
writeln!(
stdout,
" Complete: a later plan can skip {} when no input changed",
summary.work
)?;
} else {
writeln!(
stdout,
" Incomplete ({}): plans keep widening {}",
summary.bypasses.join(", "),
summary.work
)?;
for unit in &summary.unobserved_units {
writeln!(stdout, " not observed: {unit}")?;
}
}
Ok(())
}
fn build_work_plan(ctx: &WorkspaceContext, opts: &PlanOptions) -> RailResult<crate::planning::WorkPlan> {
let comparison = opts.comparison.clone().resolve(ctx, opts.all)?;
let planning_index = collect_planning_index(ctx, &comparison)?;
let semantic_changes = crate::change_detection::semantic::analyze(
ctx,
planning_index.paths(),
comparison.base(),
comparison.object_head(),
)?;
let base = ctx.git()?.git().resolve_reference(comparison.base())?;
let head_commit = comparison.object_head().map_or_else(
|| {
ctx.planning_head_commit()
.map(str::to_string)
.map_or_else(|| ctx.git()?.git().head_commit(), Ok)
},
|head| ctx.git()?.git().resolve_reference(head),
)?;
let cargo_configuration_identity = ctx.planning_cargo_configuration_identity()?;
let toolchain_identity = planning_toolchain_identity(ctx)?;
let authority = crate::planning::WorkPlanAuthority {
base,
head: comparison
.object_head()
.map_or_else(|| "WORKTREE".to_string(), |_| head_commit.clone()),
head_commit,
capture: ctx
.snapshot_id()
.map(|snapshot| snapshot.to_string())
.or_else(|| ctx.planning_snapshot_id()),
target_identity: planning_target_identity(&cargo_configuration_identity, &toolchain_identity),
cargo_configuration_identity,
toolchain_identity,
};
crate::planning::build_work_plan(
ctx,
planning_index,
authority,
&semantic_changes,
&opts.evidence,
opts.all,
)
}
fn collect_planning_index(
ctx: &WorkspaceContext,
comparison: &ResolvedComparison,
) -> RailResult<crate::planning::PlanningIndex> {
match comparison {
ResolvedComparison::Objects { from, to } => {
crate::planning::PlanningIndex::from_objects(ctx, ctx.object_source_changes(from, to)?, from, to)
}
ResolvedComparison::Worktree { base } => {
let changes = if let Some(capture) = ctx.planning_source_capture() {
capture.changes_from(ctx.git()?.git(), base)?
} else if let Some(capture) = ctx.source_capture() {
capture.changes_from(ctx.git()?.git(), base)?
} else {
ctx.capture_worktree_source()?.changes_from(ctx.git()?.git(), base)?
};
crate::planning::PlanningIndex::from_worktree(ctx, changes, base)
}
}
}
fn planning_toolchain_identity(ctx: &WorkspaceContext) -> RailResult<String> {
if ctx.snapshot_id().is_some() {
Ok(ctx.snapshot()?.toolchain_fingerprint().to_string())
} else {
Ok(toolchain_fingerprint(ctx.workspace_root()))
}
}
fn planning_target_identity(configuration: &str, toolchain: &str) -> String {
let input = format!(
"planning-target-v1\0{}\0{}\0{}\0{}",
std::env::consts::OS,
std::env::consts::ARCH,
configuration,
toolchain
);
format!(
"planning-target-v1:sha256:{}",
crate::source::ContentDigest::sha256(input.as_bytes())
)
}
#[cfg(test)]
mod saved_plan_input_tests {
use super::read_saved_plan;
#[test]
fn bounded_saved_plan_input_rejects_one_excess_byte() {
assert_eq!(
read_saved_plan(std::io::Cursor::new(b"plan"), 4, 0, "fixture").expect("bounded input"),
b"plan"
);
assert!(
read_saved_plan(std::io::Cursor::new(b"plans"), 4, 0, "fixture")
.expect_err("oversized input")
.to_string()
.contains("no larger than 4 bytes")
);
}
}