use alloc::{format, string::String};
use super::validate::validate_repository_path;
use super::{
PROBE_SCHEMA, PathKind, PathState, PlanCheckReport, PlanEnvironmentProbe, PlanError,
PlanProbeManifest, PlanTopology, ProbeExpectation, SourceProbe,
};
pub fn check_plan_environment<P: SourceProbe>(
topology: &PlanTopology,
manifest: &PlanProbeManifest,
source: &P,
) -> Result<PlanCheckReport, PlanError> {
if manifest.schema != PROBE_SCHEMA {
return Err(environment(format!(
"probe schema must be `{PROBE_SCHEMA}`"
)));
}
if manifest.probes.is_empty() {
return Err(environment("probe manifest has zero probes"));
}
let identity = observe("worktree identity", source.worktree_identity())?;
if identity != manifest.worktree_identity {
return Err(environment(format!(
"worktree root was symlinked or replaced: expected `{}`, observed `{identity}`",
manifest.worktree_identity
)));
}
let baseline = observe("baseline", source.baseline())?;
if baseline != manifest.baseline {
return Err(environment(format!(
"baseline drift: expected `{}`, observed `{baseline}`",
manifest.baseline
)));
}
let disk_mib = observe("available disk", source.available_disk_mib())?;
if disk_mib < topology.capacity.disk_min_mib {
return Err(environment(format!(
"available disk {disk_mib} MiB is below required {} MiB",
topology.capacity.disk_min_mib
)));
}
let model_quota = observe("model quota", source.model_quota())?;
if model_quota < topology.capacity.model_quota {
return Err(environment(format!(
"model quota {model_quota} is below required {}",
topology.capacity.model_quota
)));
}
let host_process_ceiling = observe("host process ceiling", source.host_process_ceiling())?;
if host_process_ceiling < topology.capacity.host_process_ceiling {
return Err(environment(format!(
"host process ceiling {host_process_ceiling} is below planned {}",
topology.capacity.host_process_ceiling
)));
}
let project_spawn_max_parallel = observe(
"project spawn.max_parallel",
source.project_spawn_max_parallel(),
)?;
if project_spawn_max_parallel != topology.capacity.project_spawn_max_parallel {
return Err(environment(format!(
"project spawn.max_parallel drift: plan requires {}, observed {project_spawn_max_parallel}",
topology.capacity.project_spawn_max_parallel
)));
}
for probe in &manifest.probes {
match probe {
PlanEnvironmentProbe::Path {
path,
expectation,
kind,
} => {
validate_path(path)?;
let state = observe("path state", source.path_state(path))?;
match (expectation, kind, state) {
(ProbeExpectation::Create, _, PathState::Missing)
| (ProbeExpectation::Modify, PathKind::File, PathState::File)
| (ProbeExpectation::Modify, PathKind::Directory, PathState::Directory) => {}
(_, _, PathState::Symlink) => {
return Err(environment(format!(
"path probe `{path}` resolved to a symlink"
)));
}
(ProbeExpectation::Create, _, observed) => {
return Err(environment(format!(
"create path `{path}` must be absent, observed {observed:?}"
)));
}
(ProbeExpectation::Modify, expected, observed) => {
return Err(environment(format!(
"modify path `{path}` expected {expected:?}, observed {observed:?}"
)));
}
}
}
PlanEnvironmentProbe::Symbol {
path,
symbol,
expected_matches,
} => {
validate_path(path)?;
if symbol.is_empty() || *expected_matches == 0 {
return Err(environment(
"symbol probe requires a symbol and nonzero count",
));
}
let observed = observe("symbol count", source.symbol_matches(path, symbol))?;
if observed != *expected_matches {
return Err(environment(format!(
"symbol `{symbol}` in `{path}` expected {expected_matches} matches, observed {observed}"
)));
}
}
PlanEnvironmentProbe::Interface {
path,
schema,
version,
} => {
validate_path(path)?;
if schema.is_empty() || version.is_empty() {
return Err(environment("interface probe requires schema and version"));
}
let observed = observe("interface", source.interface(path))?;
if observed.schema != *schema || observed.version != *version {
return Err(environment(format!(
"interface `{path}` expected `{schema}` version `{version}`, observed `{}` version `{}`",
observed.schema, observed.version
)));
}
}
PlanEnvironmentProbe::Command {
argv,
expected_exit,
semantic_marker,
} => {
validate_nonmutating_argv(argv)?;
if semantic_marker.is_empty() {
return Err(environment("command semantic marker is empty"));
}
let observed = observe("command", source.run(argv))?;
if observed.exit != *expected_exit {
return Err(environment(format!(
"command `{}` expected exit {expected_exit}, observed {}",
argv.join(" "),
observed.exit
)));
}
if !observed.stdout.contains(semantic_marker)
&& !observed.stderr.contains(semantic_marker)
{
return Err(environment(format!(
"command `{}` omitted semantic marker `{semantic_marker}`",
argv.join(" ")
)));
}
}
}
}
let final_identity = observe("final worktree identity", source.worktree_identity())?;
if final_identity != identity {
return Err(environment(
"worktree root was replaced while environment probes were running",
));
}
Ok(PlanCheckReport {
schema: "shepherd.plan-check/1".into(),
run: topology.run.clone(),
worktree_identity: identity,
baseline,
probe_count: manifest.probes.len(),
available_disk_mib: disk_mib,
model_quota,
host_process_ceiling,
project_spawn_max_parallel,
})
}
fn validate_nonmutating_argv(argv: &[String]) -> Result<(), PlanError> {
if argv.is_empty()
|| argv.iter().any(|argument| {
argument.is_empty()
|| argument.chars().any(char::is_control)
|| argument
.chars()
.any(|character| matches!(character, ';' | '&' | '|' | '<' | '>' | '`'))
|| argument.contains("$(")
|| argument.starts_with('@')
})
{
return Err(environment(
"probe must use nonmutating argv without shell syntax",
));
}
let safe = match argv {
[git, rev_parse, verify, head]
if git == "git"
&& rev_parse == "rev-parse"
&& verify == "--verify"
&& matches!(head.as_str(), "HEAD" | "HEAD^{commit}") =>
{
true
}
[rg, count, fixed, pattern, path]
if rg == "rg"
&& count == "--count-matches"
&& fixed == "--fixed-strings"
&& !pattern.starts_with('-')
&& validate_repository_path(path).is_ok() =>
{
true
}
_ => false,
};
if !safe {
return Err(environment(format!(
"probe is not an allowlisted nonmutating argv: `{}`",
argv.join(" ")
)));
}
Ok(())
}
fn validate_path(path: &str) -> Result<(), PlanError> {
validate_repository_path(path)
.map_err(|message| environment(format!("invalid probe path `{path}`: {message}")))
}
fn observe<T, E: core::fmt::Display>(
operation: &str,
result: Result<T, E>,
) -> Result<T, PlanError> {
result.map_err(|error| environment(format!("{operation} failed: {error}")))
}
fn environment(message: impl Into<String>) -> PlanError {
PlanError::Environment(message.into())
}