Skip to main content

git_slop/
config.rs

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}