use std::path::PathBuf;
use algocline_core::AppDir;
use super::path::ContainedPath;
pub(super) fn evals_dir(app_dir: &AppDir) -> PathBuf {
app_dir.evals_dir()
}
pub(super) fn splice_response_string(json_str: &str, key: &str, value: &str) -> String {
if let Ok(serde_json::Value::Object(mut map)) = serde_json::from_str(json_str) {
map.insert(
key.to_string(),
serde_json::Value::String(value.to_string()),
);
return serde_json::Value::Object(map).to_string();
}
json_str.to_string()
}
pub(super) fn splice_response_warnings(json_str: &str, key: &str, values: &[String]) -> String {
if values.is_empty() {
return json_str.to_string();
}
if let Ok(serde_json::Value::Object(mut map)) = serde_json::from_str(json_str) {
map.insert(key.to_string(), serde_json::json!(values));
return serde_json::Value::Object(map).to_string();
}
json_str.to_string()
}
pub(super) fn save_eval_result(
app_dir: &AppDir,
strategy: &str,
result_json: &str,
) -> Result<(), String> {
let dir = evals_dir(app_dir);
std::fs::create_dir_all(&dir)
.map_err(|e| format!("failed to create evals dir {}: {e}", dir.display()))?;
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default();
let timestamp = now.as_secs();
let eval_id = format!("{strategy}_{timestamp}");
let parsed: serde_json::Value = serde_json::from_str(result_json)
.map_err(|e| format!("failed to parse eval result JSON: {e}"))?;
let path = ContainedPath::child(&dir, &format!("{eval_id}.json"))
.map_err(|e| format!("invalid eval_id {eval_id}: {e}"))?;
std::fs::write(&path, result_json)
.map_err(|e| format!("failed to write eval result {}: {e}", path.display()))?;
let meta = build_meta(&eval_id, strategy, timestamp, &parsed);
let meta_path = ContainedPath::child(&dir, &format!("{eval_id}.meta.json"))
.map_err(|e| format!("invalid eval_id meta {eval_id}: {e}"))?;
let meta_str =
serde_json::to_string(&meta).map_err(|e| format!("failed to serialize eval meta: {e}"))?;
std::fs::write(&meta_path, meta_str)
.map_err(|e| format!("failed to write eval meta {}: {e}", meta_path.display()))
}
pub(super) fn build_meta(
eval_id: &str,
strategy: &str,
timestamp: u64,
parsed: &serde_json::Value,
) -> serde_json::Value {
let result_obj = parsed.get("result");
let stats_obj = parsed.get("stats");
let aggregated = result_obj.and_then(|r| r.get("aggregated"));
serde_json::json!({
"eval_id": eval_id,
"strategy": strategy,
"timestamp": timestamp,
"pass_rate": aggregated.and_then(|a| a.get("pass_rate")),
"mean_score": aggregated.and_then(|a| a.get("scores")).and_then(|s| s.get("mean")),
"total_cases": aggregated.and_then(|a| a.get("total")),
"passed": aggregated.and_then(|a| a.get("passed")),
"llm_calls": stats_obj.and_then(|s| s.get("auto")).and_then(|a| a.get("llm_calls")),
"elapsed_ms": stats_obj.and_then(|s| s.get("auto")).and_then(|a| a.get("elapsed_ms")),
"summary": result_obj.and_then(|r| r.get("summary")),
})
}
pub(super) fn list_eval_history(
dir: &std::path::Path,
strategy: Option<&str>,
limit: usize,
) -> Result<String, String> {
if !dir.exists() {
return Ok(serde_json::json!({ "evals": [] }).to_string());
}
let mut entries: Vec<serde_json::Value> = Vec::new();
let mut warnings: Vec<String> = Vec::new();
let read_dir = std::fs::read_dir(dir).map_err(|e| format!("Failed to read evals dir: {e}"))?;
for entry in read_dir.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("json") {
continue;
}
if path
.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.contains(".meta."))
{
continue;
}
let stem = match path.file_stem().and_then(|s| s.to_str()) {
Some(s) => s,
None => continue,
};
let meta_path = match ContainedPath::child(dir, &format!("{stem}.meta.json")) {
Ok(p) => p,
Err(_) => continue,
};
let meta: Option<serde_json::Value> = if meta_path.exists() {
match std::fs::read_to_string(&*meta_path) {
Ok(s) => match serde_json::from_str::<serde_json::Value>(&s) {
Ok(v) => Some(v),
Err(e) => {
warnings.push(format!("eval meta parse {}: {e}", meta_path.display()));
None
}
},
Err(e) => {
warnings.push(format!("eval meta read {}: {e}", meta_path.display()));
None
}
}
} else {
None
};
if let Some(meta) = meta {
if let Some(filter) = strategy {
if meta.get("strategy").and_then(|s| s.as_str()) != Some(filter) {
continue;
}
}
entries.push(meta);
}
}
entries.sort_by(|a, b| {
let ts_a = a
.get("timestamp")
.and_then(serde_json::Value::as_u64)
.unwrap_or(0);
let ts_b = b
.get("timestamp")
.and_then(serde_json::Value::as_u64)
.unwrap_or(0);
ts_b.cmp(&ts_a)
});
entries.truncate(limit);
let mut response = serde_json::json!({ "evals": entries });
if !warnings.is_empty() {
response["warnings"] = serde_json::json!(warnings);
}
Ok(response.to_string())
}
pub(super) fn escape_for_lua_sq(s: &str) -> String {
s.replace('\\', "\\\\")
.replace('\'', "\\'")
.replace('\n', "\\n")
.replace('\r', "\\r")
}
pub(super) fn extract_strategy_from_id(eval_id: &str) -> Option<&str> {
eval_id.rsplit_once('_').map(|(prefix, _)| prefix)
}
#[cfg(test)]
#[allow(clippy::items_after_test_module)]
mod splice_warnings_tests {
use super::splice_response_warnings;
#[test]
fn empty_warnings_returns_original_unchanged() {
let json = r#"{"status":"ok"}"#;
assert_eq!(splice_response_warnings(json, "warnings", &[]), json);
}
#[test]
fn non_empty_warnings_added_as_array() {
let json = r#"{"status":"ok"}"#;
let warnings = vec!["alc.lock parse error".to_string()];
let out = splice_response_warnings(json, "warnings", &warnings);
let v: serde_json::Value = serde_json::from_str(&out).unwrap();
let arr = v["warnings"].as_array().expect("warnings must be array");
assert_eq!(arr.len(), 1);
assert_eq!(arr[0].as_str(), Some("alc.lock parse error"));
}
#[test]
fn non_object_json_returned_unchanged() {
let json = r#""just a string""#;
let warnings = vec!["something".to_string()];
assert_eq!(splice_response_warnings(json, "warnings", &warnings), json);
}
}
pub(super) fn save_compare_result(
app_dir: &AppDir,
eval_id_a: &str,
eval_id_b: &str,
result_json: &str,
) -> Result<(), String> {
let dir = evals_dir(app_dir);
let filename = format!("compare_{eval_id_a}_vs_{eval_id_b}.json");
let path = ContainedPath::child(&dir, &filename)
.map_err(|e| format!("invalid compare filename {filename}: {e}"))?;
std::fs::write(&path, result_json)
.map_err(|e| format!("failed to write compare result {}: {e}", path.display()))
}
#[cfg(test)]
mod list_eval_history_tests {
use super::list_eval_history;
fn warnings_from_json(json: &str) -> Vec<String> {
let v: serde_json::Value = serde_json::from_str(json).expect("must be valid JSON");
v.get("warnings")
.and_then(|w| w.as_array())
.map(|arr| {
arr.iter()
.filter_map(|x| x.as_str().map(|s| s.to_string()))
.collect()
})
.unwrap_or_default()
}
#[test]
fn absent_dir_returns_empty_no_warnings() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join("evals");
let result = list_eval_history(&dir, None, 50).unwrap();
let v: serde_json::Value = serde_json::from_str(&result).unwrap();
let evals = v["evals"].as_array().unwrap();
assert!(evals.is_empty(), "absent dir must return empty evals");
let warns = warnings_from_json(&result);
assert!(
warns.is_empty(),
"absent dir must produce no warnings, got {warns:?}"
);
}
#[test]
fn meta_absent_is_silent_no_warning() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join("evals");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("cot_1.json"), b"{}").unwrap();
let result = list_eval_history(&dir, None, 50).unwrap();
let warns = warnings_from_json(&result);
assert!(
warns.is_empty(),
"absent meta file must produce no warnings, got {warns:?}"
);
}
#[test]
fn corrupt_meta_surfaces_warning() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join("evals");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("cot_1.json"), b"{}").unwrap();
std::fs::write(dir.join("cot_1.meta.json"), b"not json {{{{").unwrap();
let result = list_eval_history(&dir, None, 50).unwrap();
let warns = warnings_from_json(&result);
assert!(
!warns.is_empty(),
"corrupt meta.json must produce at least one warning, got {warns:?}"
);
assert!(
warns[0].contains("parse"),
"warning must mention parse: {}",
warns[0]
);
}
#[test]
fn valid_meta_included_no_warnings() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join("evals");
std::fs::create_dir_all(&dir).unwrap();
let meta = r#"{"eval_id":"cot_1","strategy":"cot","timestamp":1}"#;
std::fs::write(dir.join("cot_1.json"), b"{}").unwrap();
std::fs::write(dir.join("cot_1.meta.json"), meta).unwrap();
let result = list_eval_history(&dir, None, 50).unwrap();
let v: serde_json::Value = serde_json::from_str(&result).unwrap();
let evals = v["evals"].as_array().unwrap();
assert_eq!(evals.len(), 1, "valid meta must appear in evals");
let warns = warnings_from_json(&result);
assert!(
warns.is_empty(),
"valid meta must produce no warnings, got {warns:?}"
);
}
}