1use 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#[derive(Debug, Clone, PartialEq)]
25pub struct RepoConfig {
26 pub model: Option<String>,
27 pub skip_llm_threshold: f64,
28 pub abort_threshold: f64,
29 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
78fn 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
103pub 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
112pub 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}