1use std::fs;
2use std::path::{Path, PathBuf};
3
4use anyhow::{Context, Result, bail};
5use serde_json::{Value, json};
6
7pub const DEFAULT_SLOP_GITIGNORE: &str = "/latest/\n/runs/\n/cache/\n";
8
9#[derive(Debug, Clone)]
10pub struct InitResult {
11 pub config: &'static str,
12 pub gitignore: &'static str,
13}
14
15pub fn slop_dir(repo_root: &Path) -> PathBuf {
16 repo_root.join(".slop")
17}
18
19pub fn config_path(repo_root: &Path) -> PathBuf {
20 slop_dir(repo_root).join("config.yaml")
21}
22
23pub fn latest_dir(repo_root: &Path) -> PathBuf {
24 slop_dir(repo_root).join("latest")
25}
26
27pub fn runs_dir(repo_root: &Path) -> PathBuf {
28 slop_dir(repo_root).join("runs")
29}
30
31pub fn cache_dir(repo_root: &Path) -> PathBuf {
32 slop_dir(repo_root).join("cache")
33}
34
35pub fn default_config() -> Value {
36 json!({
37 "schema_version": 2,
38 "inventory": {
39 "ignore_globs": [
40 "uv.lock", "poetry.lock", "Pipfile.lock", "package-lock.json",
41 "pnpm-lock.yaml", "yarn.lock", "bun.lock", "bun.lockb",
42 "Cargo.lock", "Gemfile.lock", "composer.lock", "Podfile.lock"
43 ]
44 },
45 "tokenization": {
46 "context_tokenizer_name": "cl100k_base",
47 "context_bands": {
48 "compact_max_tokens": 3072,
49 "healthy_max_tokens": 8000,
50 "warning_max_tokens": 10000
51 }
52 },
53 "history": {
54 "churn_window_days": 180,
55 "age_half_life_days": 180,
56 "follow_renames": false
57 },
58 "scoring": {
59 "context_weight": 0.60,
60 "age_weight": 0.20,
61 "churn_weight": 0.20
62 },
63 "organization": {
64 "candidate_file_limit": 500,
65 "min_file_tokens": 300,
66 "max_file_tokens": 50000,
67 "shingle_size": 8,
68 "window_step": 32,
69 "min_similarity": 0.72,
70 "max_pairs_per_file": 20,
71 "min_cochange_support": 3,
72 "min_coupling_lift": 2.0
73 },
74 "verification": {
75 "test_path_markers": [
76 "test/", "tests/", "spec/", "__tests__/", ".test.", ".spec."
77 ]
78 },
79 "navigation": {"top_distinctive_terms": 5},
80 "blast_radius": {},
81 "stewardship": {"bot_name_markers": ["bot", "[bot]"]},
82 "semantic_drift": {"top_term_limit": 25},
83 "health": {
84 "data_context_min_bytes": 262144,
85 "folder_bands": {
86 "compact_max_direct_tokens": 31999,
87 "healthy_max_direct_tokens": 128000,
88 "warning_max_direct_tokens": 256000,
89 "warning_max_direct_files": 17,
90 "refactor_required_max_direct_files": 37
91 },
92 "summary_top_files": 10,
93 "summary_top_folders": 10
94 },
95 "check": {
96 "fail_on_context_band": "critical",
97 "fail_on_slop_band": "critical"
98 }
99 })
100}
101
102fn deep_merge(base: &mut Value, override_value: Value) {
103 match (base, override_value) {
104 (Value::Object(base_map), Value::Object(override_map)) => {
105 for (key, value) in override_map {
106 if let Some(existing) = base_map.get_mut(&key) {
107 deep_merge(existing, value);
108 } else {
109 base_map.insert(key, value);
110 }
111 }
112 }
113 (base_slot, value) => *base_slot = value,
114 }
115}
116
117fn normalize_legacy(mut payload: Value) -> Result<Value> {
118 let Some(object) = payload.as_object_mut() else {
119 bail!("config.yaml must decode to a mapping.");
120 };
121 let schema = object
122 .get("schema_version")
123 .and_then(Value::as_u64)
124 .unwrap_or(1);
125 if schema != 1 && schema != 2 {
126 bail!("config.yaml must declare schema_version: 1 or schema_version: 2.");
127 }
128 if schema == 1 {
129 object.insert("schema_version".into(), json!(2));
130 let tokenizer_name = object
131 .remove("tokenizer")
132 .and_then(|item| item.get("name").cloned());
133 let legacy_bands = object.remove("context_bands");
134 if tokenizer_name.is_some() || legacy_bands.is_some() {
135 let tokenization = object.entry("tokenization").or_insert_with(|| json!({}));
136 let Some(tokenization) = tokenization.as_object_mut() else {
137 bail!("tokenization must be a mapping.");
138 };
139 if let Some(name) = tokenizer_name {
140 tokenization.entry("context_tokenizer_name").or_insert(name);
141 }
142 if let Some(bands) = legacy_bands {
143 tokenization.entry("context_bands").or_insert(bands);
144 }
145 }
146 }
147 if let Some(check) = object.get_mut("check").and_then(Value::as_object_mut) {
148 let legacy = check.remove("fail_on_priority_band");
149 if !check.contains_key("fail_on_slop_band") {
150 if let Some(legacy) = legacy {
151 let mapped = match legacy.as_str().unwrap_or_default() {
152 "watchlist" => "low",
153 "needs_refactor" => "moderate",
154 "should_refactor" => "high",
155 "must_refactor" => "critical",
156 other => other,
157 };
158 check.insert("fail_on_slop_band".into(), json!(mapped));
159 }
160 }
161 }
162 Ok(payload)
163}
164
165fn add_legacy_aliases(mut payload: Value) -> Value {
166 let tokenizer_name = pointer_str(&payload, "/tokenization/context_tokenizer_name")
167 .unwrap_or("cl100k_base")
168 .to_string();
169 let bands = payload
170 .pointer("/tokenization/context_bands")
171 .cloned()
172 .unwrap_or_else(|| json!({}));
173 if let Some(object) = payload.as_object_mut() {
174 object.insert("tokenizer".into(), json!({"name": tokenizer_name}));
175 object.insert("context_bands".into(), bands);
176 }
177 payload
178}
179
180pub fn load(repo_root: &Path) -> Result<Value> {
181 let path = config_path(repo_root);
182 if !path.exists() {
183 return Ok(add_legacy_aliases(default_config()));
184 }
185 let source =
186 fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?;
187 let yaml: serde_yaml::Value =
188 serde_yaml::from_str(&source).with_context(|| format!("invalid {}", path.display()))?;
189 let override_value =
190 serde_json::to_value(yaml).context("config.yaml contains unsupported YAML values")?;
191 let override_value = normalize_legacy(override_value)?;
192 let mut merged = default_config();
193 deep_merge(&mut merged, override_value);
194 Ok(add_legacy_aliases(merged))
195}
196
197pub fn ensure_state_dirs(repo_root: &Path) -> Result<()> {
198 for path in [
199 slop_dir(repo_root),
200 latest_dir(repo_root),
201 runs_dir(repo_root),
202 cache_dir(repo_root),
203 ] {
204 fs::create_dir_all(&path)
205 .with_context(|| format!("failed to create {}", path.display()))?;
206 }
207 Ok(())
208}
209
210pub fn initialize(repo_root: &Path, force: bool) -> Result<InitResult> {
211 ensure_state_dirs(repo_root)?;
212 let config_target = config_path(repo_root);
213 let config_status = if force || !config_target.exists() {
214 let yaml = serde_yaml::to_string(&default_config())?;
215 fs::write(&config_target, yaml)
216 .with_context(|| format!("failed to write {}", config_target.display()))?;
217 "written"
218 } else {
219 "kept"
220 };
221 let gitignore_target = slop_dir(repo_root).join(".gitignore");
222 let gitignore_status = if force || !gitignore_target.exists() {
223 fs::write(&gitignore_target, DEFAULT_SLOP_GITIGNORE)
224 .with_context(|| format!("failed to write {}", gitignore_target.display()))?;
225 "written"
226 } else {
227 "kept"
228 };
229 Ok(InitResult {
230 config: config_status,
231 gitignore: gitignore_status,
232 })
233}
234
235pub fn pointer_u64(value: &Value, pointer: &str, default: u64) -> u64 {
236 value
237 .pointer(pointer)
238 .and_then(Value::as_u64)
239 .unwrap_or(default)
240}
241
242pub fn pointer_f64(value: &Value, pointer: &str, default: f64) -> f64 {
243 value
244 .pointer(pointer)
245 .and_then(Value::as_f64)
246 .unwrap_or(default)
247}
248
249pub fn pointer_bool(value: &Value, pointer: &str, default: bool) -> bool {
250 value
251 .pointer(pointer)
252 .and_then(Value::as_bool)
253 .unwrap_or(default)
254}
255
256pub fn pointer_str<'a>(value: &'a Value, pointer: &str) -> Option<&'a str> {
257 value.pointer(pointer).and_then(Value::as_str)
258}
259
260pub fn pointer_strings(value: &Value, pointer: &str) -> Vec<String> {
261 value
262 .pointer(pointer)
263 .and_then(Value::as_array)
264 .into_iter()
265 .flatten()
266 .filter_map(Value::as_str)
267 .map(ToOwned::to_owned)
268 .collect()
269}
270
271#[cfg(test)]
272mod tests {
273 use std::fs;
274
275 use serde_json::{Value, json};
276 use tempfile::tempdir;
277
278 use super::{config_path, default_config, load};
279
280 fn load_payload(payload: Value) -> Value {
281 let repository = tempdir().expect("temporary repository");
282 let path = config_path(repository.path());
283 fs::create_dir_all(path.parent().expect("config parent")).expect("config directory");
284 fs::write(
285 &path,
286 serde_yaml::to_string(&payload).expect("serialize config"),
287 )
288 .expect("write config");
289 load(repository.path()).expect("load config")
290 }
291
292 #[test]
293 fn default_config_uses_the_schema_two_contract() {
294 let config = default_config();
295
296 assert_eq!(config["schema_version"], 2);
297 assert_eq!(config["check"]["fail_on_slop_band"], "critical");
298 assert!(config["check"].get("fail_on_priority_band").is_none());
299 for section in ["tokenization", "organization", "verification"] {
300 assert!(config.get(section).is_some(), "missing default {section}");
301 }
302 }
303
304 #[test]
305 fn schema_one_payload_defaults_and_aliases_are_normalized_to_schema_two() {
306 let normalized = load_payload(json!({
307 "tokenizer": {"name": "r50k_base"},
308 "context_bands": {"warning_max_tokens": 9_000},
309 "history": {"follow_renames": true}
310 }));
311
312 assert_eq!(normalized["schema_version"], 2);
313 assert_eq!(
314 normalized["tokenization"]["context_tokenizer_name"],
315 "r50k_base"
316 );
317 assert_eq!(
318 normalized["tokenization"]["context_bands"]["warning_max_tokens"],
319 9_000
320 );
321 assert_eq!(
322 normalized["tokenization"]["context_bands"]["compact_max_tokens"],
323 3_072
324 );
325 assert_eq!(normalized["history"]["follow_renames"], true);
326 assert_eq!(normalized["tokenizer"]["name"], "r50k_base");
327 assert_eq!(normalized["context_bands"]["warning_max_tokens"], 9_000);
328 }
329
330 #[test]
331 fn every_legacy_priority_band_maps_to_its_slop_band() {
332 for (legacy, expected) in [
333 ("watchlist", "low"),
334 ("needs_refactor", "moderate"),
335 ("should_refactor", "high"),
336 ("must_refactor", "critical"),
337 ] {
338 let normalized = load_payload(json!({
339 "schema_version": 2,
340 "check": {"fail_on_priority_band": legacy}
341 }));
342
343 assert_eq!(normalized["check"]["fail_on_slop_band"], expected);
344 assert!(
345 normalized["check"].get("fail_on_priority_band").is_none(),
346 "legacy key survived normalization for {legacy}"
347 );
348 }
349 }
350
351 #[test]
352 fn new_slop_band_wins_and_the_legacy_key_is_always_removed() {
353 let normalized = load_payload(json!({
354 "schema_version": 2,
355 "check": {
356 "fail_on_priority_band": "must_refactor",
357 "fail_on_slop_band": "moderate"
358 }
359 }));
360
361 assert_eq!(normalized["check"]["fail_on_slop_band"], "moderate");
362 assert!(normalized["check"].get("fail_on_priority_band").is_none());
363 }
364}