use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use serde::Deserialize;
use mira::protocol::RunResult;
use mira::report::{self, Format};
use mira::run::RunMeta;
pub const DEFAULT_RESULTS_DIR: &str = "./results";
#[derive(Debug, Default, Deserialize)]
pub struct Config {
#[serde(default)]
pub results: ResultsConfig,
#[serde(default)]
pub environment: EnvironmentConfig,
#[serde(default)]
pub presets: BTreeMap<String, Preset>,
#[serde(default, skip)]
base: Option<PathBuf>,
}
#[derive(Debug, Default, Clone, Deserialize)]
pub struct Preset {
#[serde(default)]
pub filter: Option<String>,
#[serde(default)]
pub tag: Option<String>,
#[serde(default)]
pub targets: Vec<String>,
#[serde(default)]
pub evals: Vec<String>,
#[serde(default)]
pub axes: BTreeMap<String, Vec<String>>,
}
impl Config {
pub fn preset(&self, name: &str) -> Result<Preset, String> {
self.presets.get(name).cloned().ok_or_else(|| {
let mut names: Vec<&str> = self.presets.keys().map(String::as_str).collect();
names.sort_unstable();
let known = if names.is_empty() {
"none defined in mira.toml".to_string()
} else {
names.join(", ")
};
format!("no such preset {name:?} (known: {known})")
})
}
}
#[derive(Debug, Default, Deserialize)]
pub struct ResultsConfig {
pub dir: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(default)]
pub struct EnvironmentConfig {
pub enabled: bool,
pub labels: BTreeMap<String, String>,
}
impl Default for EnvironmentConfig {
fn default() -> Self {
EnvironmentConfig {
enabled: true,
labels: BTreeMap::new(),
}
}
}
impl Config {
pub fn load() -> Config {
let Some(path) = find_config() else {
return Config::default();
};
match std::fs::read_to_string(&path) {
Ok(text) => match Config::parse(&text) {
Ok(mut cfg) => {
cfg.base = path.parent().map(Path::to_path_buf);
cfg
}
Err(e) => {
eprintln!("warning: ignoring {}: {e}", path.display());
Config::default()
}
},
Err(e) => {
eprintln!("warning: cannot read {}: {e}", path.display());
Config::default()
}
}
}
pub fn parse(text: &str) -> Result<Config, toml::de::Error> {
toml::from_str(text)
}
pub fn results_dir(&self) -> String {
let dir = self.results.dir.as_deref().unwrap_or(DEFAULT_RESULTS_DIR);
match &self.base {
Some(base) if !Path::new(dir).is_absolute() => {
let rel = dir.strip_prefix("./").unwrap_or(dir);
base.join(rel).to_string_lossy().into_owned()
}
_ => dir.to_string(),
}
}
}
fn find_config() -> Option<PathBuf> {
let mut dir = std::env::current_dir().ok()?;
loop {
let candidate = dir.join("mira.toml");
if candidate.is_file() {
return Some(candidate);
}
if !dir.pop() {
return None;
}
}
}
pub fn resolve_save_dir(save: &Option<String>) -> Option<String> {
if matches!(save, Some(s) if s.is_empty()) {
resolve_results_dir(save, &Config::load())
} else {
resolve_results_dir(save, &Config::default())
}
}
pub fn resolve_results_dir(save: &Option<String>, config: &Config) -> Option<String> {
match save {
None => None,
Some(s) if s.is_empty() => Some(config.results_dir()),
Some(dir) => Some(dir.clone()),
}
}
pub fn save_run(
base: &str,
meta: &RunMeta,
results: &[RunResult],
group: Option<report::Group<'_>>,
) -> std::io::Result<PathBuf> {
let run_dir = Path::new(base).join(&meta.run_id);
std::fs::create_dir_all(&run_dir)?;
std::fs::write(
run_dir.join("report.json"),
report::render_with_group(results, Format::Json, group),
)?;
std::fs::write(
run_dir.join("report.html"),
report::render_with_group(results, Format::Html, group),
)?;
let meta_json = serde_json::to_string_pretty(meta)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
std::fs::write(run_dir.join("meta.json"), meta_json)?;
Ok(run_dir)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_results_dir() {
let cfg = Config::parse("[results]\ndir = \"/tmp/evals\"\n").unwrap();
assert_eq!(cfg.results_dir(), "/tmp/evals");
}
#[test]
fn environment_capture_on_by_default() {
let cfg = Config::default();
assert!(cfg.environment.enabled);
assert!(cfg.environment.labels.is_empty());
assert!(Config::parse("").unwrap().environment.enabled);
assert!(
Config::parse("[results]\ndir = \"x\"\n")
.unwrap()
.environment
.enabled
);
}
#[test]
fn environment_config_parses_disable_and_labels() {
let cfg = Config::parse(
"[environment]\nenabled = false\n\n[environment.labels]\nteam = \"search\"\n",
)
.unwrap();
assert!(!cfg.environment.enabled);
assert_eq!(
cfg.environment.labels.get("team").map(String::as_str),
Some("search")
);
}
#[test]
fn empty_config_uses_default() {
let cfg = Config::default();
assert_eq!(cfg.results_dir(), DEFAULT_RESULTS_DIR);
assert_eq!(
Config::parse("").unwrap().results_dir(),
DEFAULT_RESULTS_DIR
);
}
#[test]
fn relative_dir_resolves_against_config_parent() {
let cfg = Config {
results: ResultsConfig {
dir: Some("./results".into()),
},
base: Some(PathBuf::from("/proj")),
..Default::default()
};
assert_eq!(cfg.results_dir(), "/proj/results");
let abs = Config {
results: ResultsConfig {
dir: Some("/var/evals".into()),
},
base: Some(PathBuf::from("/proj")),
..Default::default()
};
assert_eq!(abs.results_dir(), "/var/evals");
}
#[test]
fn resolve_precedence() {
let cfg = Config::parse("[results]\ndir = \"from-toml\"\n").unwrap();
assert_eq!(resolve_results_dir(&None, &cfg), None);
assert_eq!(
resolve_results_dir(&Some(String::new()), &cfg),
Some("from-toml".to_string())
);
assert_eq!(
resolve_results_dir(&Some("cli-dir".into()), &cfg),
Some("cli-dir".to_string())
);
}
#[test]
fn save_writes_bundle() {
use mira::run::{RUN_META_FORMAT, RunSummary};
let dir = tempfile::tempdir().unwrap();
let meta = RunMeta {
format: RUN_META_FORMAT,
run_id: "20260621T090012Z-abcd".into(),
study: "greet".into(),
study_version: None,
started_unix: 100,
finished_unix: 200,
environment: None,
summary: RunSummary::default(),
};
let run_dir = save_run(dir.path().to_str().unwrap(), &meta, &[], None).unwrap();
assert!(run_dir.join("report.json").is_file());
assert!(run_dir.join("report.html").is_file());
let meta_back = std::fs::read_to_string(run_dir.join("meta.json")).unwrap();
assert!(meta_back.contains("20260621T090012Z-abcd"));
assert!(meta_back.contains("\"started_unix\": 100"));
}
}