shepherd-core 6.6.0

The harness-agnostic shepherd engine: domain types, configuration schema, and run state. Knows nothing about any CLI, harness, or process.
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())
}