Skip to main content

jev_harness/
config.rs

1//! Repository-local configuration loader for `.jev.json`.
2//!
3//! Walks up from the current working directory (max 4 levels), merges the file over
4//! safe defaults, and caches the parsed result by file fingerprint so the semantic
5//! gates keep their sub-millisecond latency contract.
6//!
7//! Honored keys:
8//!   - `model`              -> overrides the provider default model
9//!   - `skip_llm_threshold` -> triage gate confidence threshold
10//!   - `abort_threshold`    -> trajectory abort gate threshold
11//!   - `shadow`             -> decide and report, but never change the exit code
12//!
13//! Credential keys (`api_key`, `provider`) are resolved by `JevClient::resolve_credentials`.
14
15use serde_json::Value;
16use std::fs;
17use std::path::{Path, PathBuf};
18use std::sync::{Mutex, OnceLock};
19
20pub const DEFAULT_SKIP_LLM_THRESHOLD: f64 = 0.65;
21pub const DEFAULT_ABORT_THRESHOLD: f64 = 0.70;
22
23/// Repository configuration merged over safe defaults.
24#[derive(Debug, Clone, PartialEq)]
25pub struct RepoConfig {
26    pub model: Option<String>,
27    pub skip_llm_threshold: f64,
28    pub abort_threshold: f64,
29    /// Decide and report, but never change the exit code.
30    pub shadow: bool,
31}
32
33impl Default for RepoConfig {
34    fn default() -> Self {
35        Self {
36            model: None,
37            skip_llm_threshold: DEFAULT_SKIP_LLM_THRESHOLD,
38            abort_threshold: DEFAULT_ABORT_THRESHOLD,
39            shadow: false,
40        }
41    }
42}
43
44fn find_repo_config_path(start_dir: &Path) -> Option<PathBuf> {
45    let mut current = start_dir.to_path_buf();
46    for _ in 0..4 {
47        let candidate = current.join(".jev.json");
48        if candidate.is_file() {
49            return Some(candidate);
50        }
51        if !current.pop() {
52            break;
53        }
54    }
55    None
56}
57
58fn clamp_probability(value: f64) -> f64 {
59    value.clamp(0.0, 1.0)
60}
61
62fn fingerprint(path: &Path) -> Option<String> {
63    let metadata = fs::metadata(path).ok()?;
64    let modified = metadata
65        .modified()
66        .ok()
67        .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
68        .map(|d| d.as_nanos())
69        .unwrap_or(0);
70    Some(format!(
71        "{}:{}:{}",
72        path.display(),
73        modified,
74        metadata.len()
75    ))
76}
77
78/// Parses a `.jev.json` file, never panicking: corrupted content degrades to defaults.
79fn parse_config_file(path: &Path) -> RepoConfig {
80    let mut config = RepoConfig::default();
81    if let Ok(content) = fs::read_to_string(path) {
82        if let Ok(data) = serde_json::from_str::<Value>(&content) {
83            if let Some(model) = data.get("model").and_then(|v| v.as_str()) {
84                let trimmed = model.trim();
85                if !trimmed.is_empty() {
86                    config.model = Some(trimmed.to_string());
87                }
88            }
89            if let Some(value) = data.get("skip_llm_threshold").and_then(|v| v.as_f64()) {
90                config.skip_llm_threshold = clamp_probability(value);
91            }
92            if let Some(value) = data.get("abort_threshold").and_then(|v| v.as_f64()) {
93                config.abort_threshold = clamp_probability(value);
94            }
95            if let Some(value) = data.get("shadow").and_then(|v| v.as_bool()) {
96                config.shadow = value;
97            }
98        }
99    }
100    config
101}
102
103/// Loads `.jev.json` starting the search at an explicit directory, without caching.
104/// Useful for tests and tooling that must not depend on the process working directory.
105pub fn load_repo_config_from(start_dir: &Path) -> RepoConfig {
106    match find_repo_config_path(start_dir) {
107        Some(path) => parse_config_file(&path),
108        None => RepoConfig::default(),
109    }
110}
111
112/// Loads `.jev.json` from the current working directory, cached by path + mtime + size.
113pub fn load_repo_config() -> RepoConfig {
114    static CACHE: OnceLock<Mutex<Option<(String, RepoConfig)>>> = OnceLock::new();
115    let cache = CACHE.get_or_init(|| Mutex::new(None));
116
117    let Ok(start_dir) = std::env::current_dir() else {
118        return RepoConfig::default();
119    };
120    let Some(path) = find_repo_config_path(&start_dir) else {
121        return RepoConfig::default();
122    };
123
124    let current_fingerprint = fingerprint(&path);
125    if let Some(fp) = current_fingerprint.as_ref() {
126        if let Ok(guard) = cache.lock() {
127            if let Some((cached_fp, cached_config)) = guard.as_ref() {
128                if cached_fp == fp {
129                    return cached_config.clone();
130                }
131            }
132        }
133    }
134
135    let config = parse_config_file(&path);
136
137    if let Some(fp) = current_fingerprint {
138        if let Ok(mut guard) = cache.lock() {
139            *guard = Some((fp, config.clone()));
140        }
141    }
142    config
143}