use std::fs;
use std::path::{Path, PathBuf};
use serde::Deserialize;
use serde_json::Value;
use speccoder_core::{BenchReport, CommandData, CommandResult, CommandStatus};
use crate::git::Git;
#[derive(Debug, Clone, Deserialize)]
pub struct Instance {
pub instance_id: String,
pub repo: String,
pub base_commit: String,
pub problem_statement: String,
pub test_patch: String,
#[serde(default, rename = "FAIL_TO_PASS")]
pub fail_to_pass: Value,
#[serde(default)]
pub test_cmd: Option<Value>,
}
pub struct Issue<'a> {
pub id: &'a str,
pub repo: &'a str,
pub base_commit: &'a str,
pub statement: &'a str,
}
#[derive(Debug, Clone)]
pub struct ImportOptions<'a> {
pub repos: &'a Path,
pub out: &'a Path,
pub test_cmd: Option<Vec<String>>,
pub limit: Option<usize>,
}
fn names(v: &Value) -> Vec<String> {
match v {
Value::Array(a) => a
.iter()
.filter_map(|t| t.as_str().map(str::to_owned))
.collect(),
Value::String(s) => serde_json::from_str::<Vec<String>>(s).unwrap_or_default(),
_ => Vec::new(),
}
}
fn detect_test_cmd(project: &Path) -> Option<Vec<String>> {
let cmd = |a: &[&str]| Some(a.iter().map(|s| (*s).to_owned()).collect());
if project.join("Cargo.toml").exists() {
cmd(&["cargo", "test", "--quiet"])
} else if project.join("package.json").exists() {
cmd(&["npm", "test", "--silent"])
} else if project.join("pyproject.toml").exists() || project.join("setup.py").exists() {
cmd(&["python3", "-m", "pytest", "-q"])
} else if project.join("go.mod").exists() {
cmd(&["go", "test", "./..."])
} else {
None
}
}
fn slug(id: &str) -> String {
id.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '-' {
c.to_ascii_lowercase()
} else {
'-'
}
})
.collect()
}
fn toml_list(argv: &[String]) -> String {
let items: Vec<String> = argv.iter().map(|a| format!("{a:?}")).collect();
format!("[{}]", items.join(", "))
}
fn patched_paths(patch: &str) -> Vec<String> {
patch
.lines()
.filter_map(|l| l.strip_prefix("+++ b/"))
.map(|p| p.trim().to_owned())
.collect()
}
fn checkout(src: &Path, commit: &str, dir: &Path) -> Result<(), String> {
let parent = dir.parent().ok_or("no parent directory")?;
fs::create_dir_all(parent).map_err(|e| e.to_string())?;
Git::at(parent)
.run(&[
"clone",
"--quiet",
"--no-checkout",
&src.to_string_lossy(),
&dir.to_string_lossy(),
])
.map_err(|e| e.0)?;
Git::at(dir)
.run(&["checkout", "--quiet", commit])
.map_err(|e| e.0)?;
Ok(())
}
fn import_one(inst: &Instance, opts: &ImportOptions<'_>) -> Result<PathBuf, String> {
let src = opts.repos.join(inst.repo.replace('/', "__"));
if !src.join(".git").exists() && !src.join("HEAD").exists() {
return Err(format!(
"no clone of {} at {} (git clone https://github.com/{} {})",
inst.repo,
src.display(),
inst.repo,
src.display()
));
}
let case = opts.out.join(slug(&inst.instance_id));
if case.exists() {
return Err(format!("{} exists", case.display()));
}
let project = case.join("project");
checkout(&src, &inst.base_commit, &project)?;
fs::remove_dir_all(project.join(".git")).map_err(|e| e.to_string())?;
let scratch = case.join(".oracle-checkout");
checkout(&src, &inst.base_commit, &scratch)?;
let applied = Git::at(&scratch).run_with_stdin(
&["apply", "--whitespace=nowarn", "-"],
inst.test_patch.as_bytes(),
);
if let Err(e) = applied {
let _ = fs::remove_dir_all(&case);
return Err(format!("the test patch does not apply: {}", e.0));
}
for rel in patched_paths(&inst.test_patch) {
let from = scratch.join(&rel);
let to = case.join("oracle").join(&rel);
if let Some(parent) = to.parent() {
fs::create_dir_all(parent).map_err(|e| e.to_string())?;
}
fs::copy(&from, &to).map_err(|e| format!("{rel}: {e}"))?;
}
fs::remove_dir_all(&scratch).map_err(|e| e.to_string())?;
let test_cmd = inst
.test_cmd
.as_ref()
.and_then(|v| match v {
Value::String(s) => Some(s.split_whitespace().map(str::to_owned).collect::<Vec<_>>()),
Value::Array(_) => Some(names(v)),
_ => None,
})
.or_else(|| opts.test_cmd.clone())
.or_else(|| detect_test_cmd(&project))
.ok_or("no test command: pass --test-cmd")?;
crate::format::speckit::bench::scaffold(
&project,
&slug(&inst.instance_id),
&Issue {
id: &inst.instance_id,
repo: &inst.repo,
base_commit: &inst.base_commit,
statement: &inst.problem_statement,
},
&test_cmd,
)?;
let write = |p: PathBuf, text: String| fs::write(p, text).map_err(|e| e.to_string());
write(
project.join("speccoder.toml"),
format!(
"[checks.test]\nargv = {}\ntimeout_secs = 900\n",
toml_list(&test_cmd)
),
)?;
let fail_to_pass = names(&inst.fail_to_pass);
write(
case.join("case.toml"),
format!(
"# Imported from {} ({}).\n# FAIL_TO_PASS: {}\ntask = \"T001\"\nexpect = \"complete\"\n\n[oracle]\nargv = {}\n",
inst.instance_id,
inst.repo,
fail_to_pass.join(", "),
toml_list(&test_cmd)
),
)?;
Ok(case)
}
#[must_use]
pub fn import(instances: &Path, opts: &ImportOptions<'_>) -> CommandResult {
let text = match fs::read_to_string(instances) {
Ok(t) => t,
Err(e) => {
return CommandResult::new(
"bench",
CommandStatus::NotFound,
&format!("{}: {e}", instances.display()),
);
}
};
let parsed: Result<Vec<Instance>, String> = if text.trim_start().starts_with('[') {
serde_json::from_str(&text).map_err(|e| e.to_string())
} else {
text.lines()
.filter(|l| !l.trim().is_empty())
.map(|l| serde_json::from_str(l).map_err(|e| e.to_string()))
.collect()
};
let list = match parsed {
Ok(l) => l,
Err(e) => {
return CommandResult::new(
"bench",
CommandStatus::ConfigError,
&format!("{}: {e}", instances.display()),
);
}
};
let mut report = BenchReport {
out: opts.out.display().to_string(),
imported: Vec::new(),
skipped: Vec::new(),
};
for inst in list.iter().take(opts.limit.unwrap_or(usize::MAX)) {
match import_one(inst, opts) {
Ok(_) => report.imported.push(inst.instance_id.clone()),
Err(e) => report.skipped.push(format!("{}: {e}", inst.instance_id)),
}
}
let suite = opts.out.join(crate::eval::SUITE);
if !report.imported.is_empty() && !suite.exists() {
let _ = fs::write(
&suite,
"# The model every case runs with; the key comes from the environment.\n[model]\nprovider = \"openai\"\nmodel = \"gpt-5.4-mini\"\nmax_output_tokens = 8000\n\n[budget]\nmax_turns = 40\nmax_tool_calls = 80\n",
);
}
let status = if report.imported.is_empty() {
CommandStatus::NotFound
} else {
CommandStatus::Ok
};
let message = format!(
"{} imported, {} skipped; run them with `speccoder eval {}`",
report.imported.len(),
report.skipped.len(),
opts.out.display()
);
CommandResult::new("bench", status, &message).with_data(CommandData::Bench(Box::new(report)))
}