use crate::config::Config;
use crate::paths::Paths;
use crate::spec::oracle::Oracle;
use crate::spec::{Criterion, Spec};
use serde::Serialize;
use std::path::Path;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Outcome {
Pass,
Fail,
Blocked,
Human,
}
impl Outcome {
pub fn glyph(&self) -> &'static str {
match self {
Outcome::Pass => "pass",
Outcome::Fail => "FAIL",
Outcome::Blocked => "BLOCKED",
Outcome::Human => "human",
}
}
}
#[derive(Debug, Clone, Serialize)]
pub struct OracleRun {
pub criterion: String,
pub kind: &'static str,
pub oracle: String,
pub outcome: Outcome,
#[serde(skip_serializing_if = "Option::is_none")]
pub exit_code: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub detail: Option<String>,
#[serde(skip_serializing_if = "String::is_empty")]
pub output: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct Coverage {
pub runs: Vec<OracleRun>,
pub criteria: usize,
pub executed: usize,
pub passed: usize,
pub failed: usize,
pub blocked: usize,
pub human: usize,
}
impl Coverage {
pub fn unsatisfied(&self) -> Vec<&OracleRun> {
self.runs.iter().filter(|r| r.outcome == Outcome::Fail).collect()
}
}
pub fn run_all(paths: &Paths, cfg: &Config, spec: &Spec) -> Coverage {
let mut runs = Vec::new();
for c in &spec.criteria {
if c.oracles.is_empty() {
runs.push(OracleRun {
criterion: c.id.clone(),
kind: "none",
oracle: "(no oracle)".into(),
outcome: Outcome::Fail,
exit_code: None,
detail: Some("criterion has no oracle — G0 should have caught this".into()),
output: String::new(),
});
continue;
}
for o in &c.oracles {
runs.push(execute(paths, cfg, c, o));
}
}
let count = |o: Outcome| runs.iter().filter(|r| r.outcome == o).count();
Coverage {
criteria: spec.criteria.len(),
executed: runs.iter().filter(|r| r.outcome != Outcome::Human).count(),
passed: count(Outcome::Pass),
failed: count(Outcome::Fail),
blocked: count(Outcome::Blocked),
human: count(Outcome::Human),
runs,
}
}
pub fn execute_standalone(paths: &Paths, cfg: &Config, o: &Oracle) -> OracleRun {
let synthetic = Criterion {
id: "lesson".into(),
title: String::new(),
statement: String::new(),
oracles: vec![],
bad_oracles: vec![],
line: 0,
};
execute(paths, cfg, &synthetic, o)
}
fn execute(paths: &Paths, cfg: &Config, c: &Criterion, o: &Oracle) -> OracleRun {
let base = OracleRun {
criterion: c.id.clone(),
kind: o.kind(),
oracle: o.summary(),
outcome: Outcome::Blocked,
exit_code: None,
detail: None,
output: String::new(),
};
match o {
Oracle::Human { judgement } => OracleRun {
outcome: Outcome::Human,
detail: Some(judgement.clone()),
..base
},
Oracle::Cmd { cmd, exit } => {
let (code, output) = shell(paths, cmd);
match code {
Some(actual) if actual == *exit => OracleRun {
outcome: Outcome::Pass, exit_code: Some(actual), output, ..base
},
Some(actual) => OracleRun {
outcome: Outcome::Fail,
exit_code: Some(actual),
detail: Some(format!("expected exit {exit}, got {actual}")),
output,
..base
},
None => OracleRun {
outcome: Outcome::Blocked,
detail: Some("command could not be executed".into()),
output,
..base
},
}
}
Oracle::Test { id } => {
let cmd = expand_test_template(&cfg.oracle.test_cmd, id);
let (code, output) = shell(paths, &cmd);
classify_zero(base, code, output, &cmd)
}
Oracle::Doctest { path } => {
let cmd = cfg.oracle.doctest_cmd.replace("{path}", path);
let (code, output) = shell(paths, &cmd);
classify_zero(base, code, output, &cmd)
}
Oracle::Schema { schema, target } => validate_schema(paths, base, schema, target),
}
}
fn classify_zero(base: OracleRun, code: Option<i32>, output: String, cmd: &str) -> OracleRun {
match code {
Some(0) => OracleRun { outcome: Outcome::Pass, exit_code: Some(0), output, ..base },
Some(n) => OracleRun {
outcome: Outcome::Fail,
exit_code: Some(n),
detail: Some(format!("`{cmd}` exited {n}")),
output,
..base
},
None => OracleRun {
outcome: Outcome::Blocked,
detail: Some(format!("`{cmd}` could not be executed")),
output,
..base
},
}
}
pub fn expand_test_template(template: &str, id: &str) -> String {
let (path, name) = id.split_once("::").unwrap_or(("", id));
let file = Path::new(path)
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("")
.to_string();
template
.replace("{id}", id)
.replace("{file}", &file)
.replace("{name}", name)
.replace("{path}", path)
}
fn shell(paths: &Paths, cmd: &str) -> (Option<i32>, String) {
let shell_bin = if cfg!(windows) { "cmd" } else { "sh" };
let flag = if cfg!(windows) { "/C" } else { "-c" };
let out = std::process::Command::new(shell_bin)
.arg(flag)
.arg(cmd)
.current_dir(&paths.repo)
.output();
match out {
Ok(o) => {
let text = format!(
"{}{}",
String::from_utf8_lossy(&o.stdout),
String::from_utf8_lossy(&o.stderr)
);
(o.status.code(), tail(&text, 4000))
}
Err(e) => (None, e.to_string()),
}
}
fn validate_schema(paths: &Paths, base: OracleRun, schema: &str, target: &str) -> OracleRun {
let schema_path = paths.repo.join(schema);
let target_path = paths.repo.join(target);
let read = |p: &Path| -> Result<serde_json::Value, String> {
let raw = std::fs::read_to_string(p).map_err(|e| format!("{}: {e}", p.display()))?;
serde_json::from_str(&raw).map_err(|e| format!("{}: {e}", p.display()))
};
let schema_json = match read(&schema_path) {
Ok(v) => v,
Err(e) => return OracleRun { outcome: Outcome::Blocked, detail: Some(format!("schema unreadable — {e}")), ..base },
};
let target_json = match read(&target_path) {
Ok(v) => v,
Err(e) => return OracleRun { outcome: Outcome::Blocked, detail: Some(format!("target unreadable — {e}")), ..base },
};
let validator = match jsonschema::validator_for(&schema_json) {
Ok(v) => v,
Err(e) => return OracleRun { outcome: Outcome::Blocked, detail: Some(format!("schema is invalid — {e}")), ..base },
};
let errors: Vec<String> = validator
.iter_errors(&target_json)
.map(|e| format!("{} at {}", e, e.instance_path()))
.take(10)
.collect();
if errors.is_empty() {
OracleRun { outcome: Outcome::Pass, detail: Some(format!("{target} validates")), ..base }
} else {
OracleRun {
outcome: Outcome::Fail,
detail: Some(format!("{} did not validate", target)),
output: errors.join("\n"),
..base
}
}
}
fn tail(s: &str, max: usize) -> String {
if s.len() <= max {
return s.trim_end().to_string();
}
let start = s.len() - max;
format!("…\n{}", &s[start..].trim_end())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_templates_expand_file_and_name() {
let out = expand_test_template(
"cargo test --test {file} -- --exact {name}",
"tests/trajectory.rs::one_json_object_per_line",
);
assert_eq!(out, "cargo test --test trajectory -- --exact one_json_object_per_line");
}
#[test]
fn a_bare_test_name_still_expands() {
assert_eq!(expand_test_template("cargo test {name}", "my_test"), "cargo test my_test");
}
#[test]
fn outcomes_are_distinguishable() {
assert_ne!(Outcome::Blocked.glyph(), Outcome::Fail.glyph());
assert_ne!(Outcome::Human.glyph(), Outcome::Pass.glyph());
}
#[test]
fn tail_keeps_the_end_of_long_output() {
let long = "x".repeat(5000) + "IMPORTANT";
let t = tail(&long, 100);
assert!(t.contains("IMPORTANT"), "the informative end was trimmed");
assert!(t.len() < 200);
}
}