use std::path::{Path, PathBuf};
use std::sync::Arc;
use serde::Serialize;
use serde_json::Value as JsonValue;
use harn_lexer::Lexer;
use harn_parser::{DiagnosticSeverity, Parser, TypeChecker};
#[derive(Debug, Clone, Serialize)]
pub(crate) struct DivergedFile {
pub path: String,
pub status: String,
pub lines_added: u64,
pub lines_removed: u64,
}
#[derive(Debug, Clone, Serialize)]
pub(crate) struct CounterfactualReport {
pub plan_path: String,
pub plan_paths: Vec<String>,
pub step_count: u64,
pub result: String,
pub diverged: Vec<DivergedFile>,
pub files_touched: u64,
pub lines_added: u64,
pub lines_removed: u64,
pub ops_applied: u64,
pub ops_rejected: u64,
}
#[derive(Debug)]
enum CounterfactualPlan {
EditOps(JsonValue),
DryRun(JsonValue),
}
struct CounterfactualSandbox {
session_id: String,
root: PathBuf,
}
impl CounterfactualSandbox {
fn enter(root: &Path) -> Result<Self, String> {
let session_id = format!("harn-counterfactual-{}", uuid::Uuid::now_v7());
harn_hostlib::fs::configure_session_root(&session_id, root);
harn_hostlib::fs::set_mode(&session_id, harn_hostlib::fs::FsMode::Staged, Some(root))
.map_err(|error| format!("failed to isolate counterfactual filesystem: {error}"))?;
Ok(Self {
session_id,
root: root.to_path_buf(),
})
}
}
impl Drop for CounterfactualSandbox {
fn drop(&mut self) {
let _ = harn_hostlib::fs::discard_staged(&self.session_id, &[]);
let _ = harn_hostlib::fs::remove_session_state(&self.session_id, Some(&self.root));
}
}
pub(crate) fn evaluate(plan_paths: &[PathBuf]) -> Result<CounterfactualReport, String> {
if plan_paths.is_empty() {
return Err("at least one --counterfactual plan is required".to_string());
}
let mut chained_ops = Vec::new();
let mut single_dry_run = None;
for plan_path in plan_paths {
let source = std::fs::read_to_string(plan_path).map_err(|error| {
format!(
"failed to read counterfactual plan {}: {error}",
plan_path.display()
)
})?;
match normalize_plan_value(run_plan_source(&source, plan_path)?, plan_path)? {
CounterfactualPlan::EditOps(JsonValue::Array(ops)) => chained_ops.extend(ops),
CounterfactualPlan::EditOps(other) => {
return Err(format!(
"counterfactual plan {} must return a list of edit ops, got {}",
plan_path.display(),
json_type_name(&other)
));
}
CounterfactualPlan::DryRun(dry_run) if plan_paths.len() == 1 => {
single_dry_run = Some(dry_run);
}
CounterfactualPlan::DryRun(_) => {
return Err(format!(
"counterfactual plan {} returned an edit_dry_run result, which cannot be \
chained; return the raw edit-op list instead",
plan_path.display()
));
}
}
}
let dry_run = match single_dry_run {
Some(dry_run) => dry_run,
None => run_edit_dry_run(JsonValue::Array(chained_ops), &plan_paths[0])?,
};
project_divergence(&dry_run, plan_paths)
}
fn run_plan_source(source: &str, plan_path: &Path) -> Result<JsonValue, String> {
let source = source.to_string();
let plan_path = plan_path.to_path_buf();
std::thread::Builder::new()
.name("harn-counterfactual-plan".to_string())
.spawn(move || run_plan_source_inner(&source, &plan_path))
.map_err(|error| format!("failed to start counterfactual plan runner: {error}"))?
.join()
.map_err(|_| "counterfactual plan runner panicked".to_string())?
}
fn run_plan_source_inner(source: &str, plan_path: &Path) -> Result<JsonValue, String> {
let mut lexer = Lexer::new(source);
let tokens = lexer
.tokenize()
.map_err(|error| format!("counterfactual plan lex error: {error}"))?;
let mut parser = Parser::new(tokens);
let program = parser
.parse()
.map_err(|error| format!("counterfactual plan parse error: {error}"))?;
let mut checker = TypeChecker::new();
let graph = harn_modules::build(&[plan_path.to_path_buf()]);
if let Some(imported) = graph.imported_names_for_file(plan_path) {
checker = checker.with_imported_names(imported);
}
if let Some(imported) = graph.imported_type_declarations_for_file(plan_path) {
checker = checker.with_imported_type_decls(imported);
}
if let Some(imported) = graph.imported_callable_declarations_for_file(plan_path) {
checker = checker.with_imported_callable_decls(imported);
}
for diag in checker.check(&program) {
if matches!(diag.severity, DiagnosticSeverity::Error) {
return Err(format!("counterfactual plan type error: {}", diag.message));
}
}
let chunk = harn_vm::Compiler::new()
.compile(&program)
.map_err(|error| format!("counterfactual plan compile error: {error}"))?;
let source_parent = plan_path
.parent()
.filter(|p| !p.as_os_str().is_empty())
.map(Path::to_path_buf)
.unwrap_or_else(|| PathBuf::from("."));
let project_root = harn_vm::stdlib::process::find_project_root(&source_parent);
let store_base = project_root
.clone()
.unwrap_or_else(|| source_parent.clone());
let overlay = Arc::new(harn_vm::testbench::overlay_fs::OverlayFs::rooted_at(
&store_base,
));
let local = tokio::task::LocalSet::new();
futures::executor::block_on(local.run_until(async move {
let _overlay_guard = harn_vm::testbench::overlay_fs::install_overlay(overlay);
let sandbox = CounterfactualSandbox::enter(&store_base)?;
let _session_guard =
harn_vm::agent_sessions::enter_current_session(sandbox.session_id.clone());
let mut vm = harn_vm::Vm::new();
harn_vm::register_vm_stdlib(&mut vm);
crate::install_default_hostlib(&mut vm);
harn_vm::register_store_builtins(&mut vm, &store_base);
harn_vm::register_metadata_builtins(&mut vm, &store_base);
let pipeline_name = plan_path
.file_stem()
.and_then(|stem| stem.to_str())
.unwrap_or("counterfactual");
harn_vm::register_checkpoint_builtins(&mut vm, &store_base, pipeline_name);
vm.set_source_info(&plan_path.to_string_lossy(), source);
if let Some(root) = project_root.as_ref() {
vm.set_project_root(root);
}
vm.set_source_dir(&source_parent);
vm.set_harness(harn_vm::Harness::real());
let value = vm
.execute(&chunk)
.await
.map_err(|error| format!("counterfactual plan runtime error: {error}"))?;
Ok(harn_vm::llm::vm_value_to_json(&value))
}))
}
fn normalize_plan_value(value: JsonValue, plan_path: &Path) -> Result<CounterfactualPlan, String> {
match value {
JsonValue::Array(_) => Ok(CounterfactualPlan::EditOps(value)),
JsonValue::Object(ref map) if map.contains_key("per_file_unified_diff") => {
Ok(CounterfactualPlan::DryRun(value))
}
JsonValue::Object(ref map) if map.contains_key("plan") => {
Ok(CounterfactualPlan::EditOps(map["plan"].clone()))
}
other => Err(format!(
"counterfactual plan {} must `return` an edit-op list or an edit_dry_run result, \
got {} (a bare trailing expression returns nil in Harn — use `return`)",
plan_path.display(),
json_type_name(&other)
)),
}
}
fn run_edit_dry_run(plan: JsonValue, plan_path: &Path) -> Result<JsonValue, String> {
let plan_literal = serde_json::to_string(&plan)
.map_err(|error| format!("failed to serialize counterfactual plan: {error}"))?;
let driver = format!(
"import {{ edit_dry_run }} from \"std/edit\"\nreturn edit_dry_run({{plan: {plan_literal}}})\n"
);
let dir = plan_path
.parent()
.filter(|p| !p.as_os_str().is_empty())
.map(Path::to_path_buf)
.unwrap_or_else(std::env::temp_dir);
let driver_file = tempfile::Builder::new()
.prefix(".harn-counterfactual-driver-")
.suffix(".harn")
.tempfile_in(&dir)
.map_err(|error| format!("failed to stage counterfactual dry-run driver: {error}"))?;
std::fs::write(driver_file.path(), &driver)
.map_err(|error| format!("failed to write counterfactual dry-run driver: {error}"))?;
run_plan_source(&driver, driver_file.path())
}
fn project_divergence(
dry_run: &JsonValue,
plan_paths: &[PathBuf],
) -> Result<CounterfactualReport, String> {
let summary = dry_run.get("summary").cloned().unwrap_or(JsonValue::Null);
let mut diverged = Vec::new();
if let Some(entries) = dry_run
.get("per_file_unified_diff")
.and_then(JsonValue::as_array)
{
for entry in entries {
let path = entry
.get("path")
.and_then(JsonValue::as_str)
.unwrap_or_default()
.to_string();
let lines_added = entry
.get("lines_added")
.and_then(JsonValue::as_u64)
.unwrap_or(0);
let lines_removed = entry
.get("lines_removed")
.and_then(JsonValue::as_u64)
.unwrap_or(0);
let status = if lines_removed == 0 && lines_added > 0 {
"created"
} else if lines_added == 0 && lines_removed > 0 {
"deleted"
} else {
"modified"
};
diverged.push(DivergedFile {
path,
status: status.to_string(),
lines_added,
lines_removed,
});
}
}
let plan_path_labels = plan_paths
.iter()
.map(|path| path.to_string_lossy().into_owned())
.collect::<Vec<_>>();
Ok(CounterfactualReport {
plan_path: plan_path_labels.join(" -> "),
plan_paths: plan_path_labels,
step_count: plan_paths.len() as u64,
result: dry_run
.get("result")
.and_then(JsonValue::as_str)
.unwrap_or("no_ops_applied")
.to_string(),
diverged,
files_touched: summary
.get("files_touched")
.and_then(JsonValue::as_u64)
.unwrap_or(0),
lines_added: summary
.get("lines_added")
.and_then(JsonValue::as_u64)
.unwrap_or(0),
lines_removed: summary
.get("lines_removed")
.and_then(JsonValue::as_u64)
.unwrap_or(0),
ops_applied: summary
.get("ops_applied")
.and_then(JsonValue::as_u64)
.unwrap_or(0),
ops_rejected: summary
.get("ops_rejected")
.and_then(JsonValue::as_u64)
.unwrap_or(0),
})
}
fn json_type_name(value: &JsonValue) -> &'static str {
match value {
JsonValue::Null => "nil",
JsonValue::Bool(_) => "bool",
JsonValue::Number(_) => "number",
JsonValue::String(_) => "string",
JsonValue::Array(_) => "list",
JsonValue::Object(_) => "dict",
}
}
pub(crate) fn print_human(report: &CounterfactualReport) {
println!("Counterfactual: {} ({})", report.plan_path, report.result);
if report.diverged.is_empty() {
println!(" no files would diverge from the recorded outcome.");
} else {
println!(
" would touch {} file(s) (+{} / -{} lines, {} op(s) applied, {} rejected):",
report.files_touched,
report.lines_added,
report.lines_removed,
report.ops_applied,
report.ops_rejected,
);
for file in &report.diverged {
println!(
" {} {} (+{} / -{})",
file.status, file.path, file.lines_added, file.lines_removed
);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn plan_path() -> &'static Path {
Path::new("/tmp/what-if.harn")
}
fn plan_paths() -> Vec<PathBuf> {
vec![plan_path().to_path_buf()]
}
#[test]
fn projects_diverged_files_and_classifies_status_by_line_deltas() {
let dry_run = json!({
"result": "ok",
"per_file_unified_diff": [
{"path": "a.rs", "diff": "...", "lines_added": 3, "lines_removed": 0},
{"path": "b.rs", "diff": "...", "lines_added": 0, "lines_removed": 4},
{"path": "c.rs", "diff": "...", "lines_added": 2, "lines_removed": 2},
],
"summary": {
"files_touched": 3,
"lines_added": 5,
"lines_removed": 6,
"ops_applied": 3,
"ops_rejected": 0,
},
});
let report = project_divergence(&dry_run, &plan_paths()).expect("project");
assert_eq!(report.result, "ok");
assert_eq!(report.step_count, 1);
assert_eq!(report.plan_paths, vec!["/tmp/what-if.harn"]);
assert_eq!(report.files_touched, 3);
assert_eq!(report.lines_added, 5);
assert_eq!(report.lines_removed, 6);
assert_eq!(report.ops_applied, 3);
assert_eq!(report.diverged.len(), 3);
assert_eq!(report.diverged[0].status, "created");
assert_eq!(report.diverged[1].status, "deleted");
assert_eq!(report.diverged[2].status, "modified");
}
#[test]
fn empty_dry_run_projects_to_no_divergence() {
let dry_run = json!({
"result": "no_ops_applied",
"per_file_unified_diff": [],
"summary": {"files_touched": 0, "lines_added": 0, "lines_removed": 0, "ops_applied": 0, "ops_rejected": 0},
});
let report = project_divergence(&dry_run, &plan_paths()).expect("project");
assert!(report.diverged.is_empty());
assert_eq!(report.result, "no_ops_applied");
}
#[test]
fn normalize_plan_value_rejects_a_nil_plan_with_a_return_hint() {
let error = normalize_plan_value(JsonValue::Null, plan_path()).unwrap_err();
assert!(error.contains("got nil"), "error: {error}");
assert!(
error.contains("return"),
"error should hint at `return`: {error}"
);
}
#[test]
fn normalize_plan_value_passes_through_an_existing_dry_run_result() {
let dry_run = json!({
"result": "ok",
"per_file_unified_diff": [{"path": "x.rs", "diff": "...", "lines_added": 1, "lines_removed": 0}],
"summary": {"files_touched": 1},
});
let passed = normalize_plan_value(dry_run.clone(), plan_path()).expect("passthrough");
match passed {
CounterfactualPlan::DryRun(value) => assert_eq!(value, dry_run),
CounterfactualPlan::EditOps(_) => panic!("expected dry-run result"),
}
}
#[test]
fn normalize_plan_value_reads_plan_field_as_raw_edit_ops() {
let value = normalize_plan_value(json!({"plan": [{"op": "safe_text_patch"}]}), plan_path())
.expect("normalize");
match value {
CounterfactualPlan::EditOps(JsonValue::Array(ops)) => assert_eq!(ops.len(), 1),
CounterfactualPlan::EditOps(other) => panic!("expected list, got {other:?}"),
CounterfactualPlan::DryRun(_) => panic!("expected raw edit ops"),
}
}
}