Skip to main content

git_slop/
config.rs

1use std::fs;
2use std::fs::OpenOptions;
3use std::io::Write;
4use std::path::{Path, PathBuf};
5use std::process::Command;
6
7use anyhow::{Context, Result, bail};
8use globset::Glob;
9use serde_json::Map;
10use serde_json::{Value, json};
11
12use crate::model::Classification;
13
14mod lock;
15mod structured;
16pub use lock::acquire_scan_lock;
17
18pub const DEFAULT_SLOP_GITIGNORE: &str = concat!(
19    "/latest/\n",
20    "/runs/\n",
21    "/cache/\n",
22    "/scan.lock\n",
23    "/scan.lock.owner\n",
24    "/prompt-packs/\n",
25    "/diagnostic-bundle.json\n",
26    "/advice/\n",
27    "/config.yaml.bak\n",
28    "/.gitignore.bak\n",
29);
30pub const MINIMAL_CONFIG: &str = r#"# Git Slop configuration overrides.
31# Run `git slop config show --effective` to inspect every default.
32schema_version: 2
33
34# Example:
35# check:
36#   fail_on_context_band: critical
37#   fail_on_slop_band: critical
38"#;
39
40include!("config/adoption.rs");
41include!("config/storage.rs");
42
43/// Resolve the explicitly Git-private state used by baselines and ephemeral scans.
44pub fn git_runtime_dir(repo_root: &Path) -> Result<PathBuf> {
45    let output = Command::new("git")
46        .current_dir(repo_root)
47        .args([
48            "rev-parse",
49            "--path-format=absolute",
50            "--git-path",
51            "git-slop",
52        ])
53        .output()
54        .context("failed to resolve Git-private runtime directory")?;
55    if !output.status.success() {
56        bail!(
57            "git rev-parse --git-path git-slop failed: {}",
58            String::from_utf8_lossy(&output.stderr).trim()
59        );
60    }
61    let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
62    if path.is_empty() {
63        bail!("Git returned an empty private runtime directory");
64    }
65    Ok(PathBuf::from(path))
66}
67
68pub fn slop_dir(repo_root: &Path) -> PathBuf {
69    repo_root.join(".slop")
70}
71
72pub fn config_path(repo_root: &Path) -> PathBuf {
73    slop_dir(repo_root).join("config.yaml")
74}
75
76pub fn latest_dir(repo_root: &Path) -> PathBuf {
77    slop_dir(repo_root).join("latest")
78}
79
80pub fn runs_dir(repo_root: &Path) -> PathBuf {
81    slop_dir(repo_root).join("runs")
82}
83
84pub fn cache_dir(repo_root: &Path) -> PathBuf {
85    slop_dir(repo_root).join("cache")
86}
87
88/// Resolve the state root used by an ordinary `find` invocation.
89///
90/// Before adoption, ordinary scans are Git-private and cacheable. State
91/// inspection and retention commands must follow the same decision instead of
92/// pretending that the inactive `.slop/` root is authoritative.
93pub fn active_state_dir(repo_root: &Path) -> Result<PathBuf> {
94    if adoption_status(repo_root).ready() {
95        return Ok(slop_dir(repo_root));
96    }
97    let git_private = git_runtime_dir(repo_root)?.join("ephemeral");
98    let persistent = slop_dir(repo_root);
99    let marker = git_runtime_dir(repo_root)?.join("active-state");
100    if let Ok(selected) = fs::read_to_string(marker) {
101        match selected.trim() {
102            "persistent" if persistent.exists() => return Ok(persistent),
103            "git-private" if git_private.exists() => return Ok(git_private),
104            _ => {}
105        }
106    }
107    if git_private.exists() {
108        return Ok(git_private);
109    }
110    if persistent.join("latest").exists()
111        || persistent.join("runs").exists()
112        || persistent.join("cache").exists()
113    {
114        return Ok(persistent);
115    }
116    Ok(git_private)
117}
118
119pub fn mark_active_state(repo_root: &Path, persistent: bool) -> Result<()> {
120    let runtime = git_runtime_dir(repo_root)?;
121    fs::create_dir_all(&runtime)?;
122    write_text_atomically(
123        &runtime.join("active-state"),
124        if persistent {
125            "persistent\n"
126        } else {
127            "git-private\n"
128        },
129        false,
130    )?;
131    Ok(())
132}
133
134pub fn default_config() -> Value {
135    json!({
136        "schema_version": 2,
137        "inventory": {
138            "ignore_globs": [
139                "uv.lock", "poetry.lock", "Pipfile.lock", "package-lock.json",
140                "pnpm-lock.yaml", "yarn.lock", "bun.lock", "bun.lockb",
141                "Cargo.lock", "Gemfile.lock", "composer.lock", "Podfile.lock"
142            ],
143            "path_overrides": []
144        },
145        "tokenization": {
146            "context_tokenizer_name": "cl100k_base",
147            "context_bands": {
148                "compact_max_tokens": 3072,
149                "healthy_max_tokens": 8000,
150                "warning_max_tokens": 10000
151            }
152        },
153        "history": {
154            "churn_window_days": 180,
155            "age_half_life_days": 180,
156            "max_commits": 10000,
157            "follow_renames": false
158        },
159        "scoring": {
160            "context_weight": 0.60,
161            "age_weight": 0.20,
162            "churn_weight": 0.20
163        },
164        "organization": {
165            "candidate_file_limit": 500,
166            "min_file_tokens": 300,
167            "max_file_tokens": 50000,
168            "shingle_size": 8,
169            "window_step": 32,
170            "min_similarity": 0.72,
171            "max_pairs_per_file": 20,
172            "max_temporal_edges": 10000,
173            "max_commit_files": 200,
174            "min_cochange_support": 3,
175            "min_coupling_lift": 1.0
176        },
177        "verification": {
178            "test_path_markers": [
179                "test/", "tests/", "spec/", "__tests__/", ".test.", ".spec."
180            ],
181            "source_test_mappings": [],
182            "path_commands": [],
183            "commands": []
184        },
185        "navigation": {"top_distinctive_terms": 5},
186        "blast_radius": {},
187        "stewardship": {"bot_name_markers": ["bot", "[bot]"]},
188        "semantic_drift": {"top_term_limit": 25},
189        "resources": {
190            "memory_budget_mb": 1024,
191            "large_file_bytes": 2097152,
192            "cache_max_bytes": 536870912,
193            "cache_max_entries": 10000
194        },
195        "output": {
196            "retention_runs": 20,
197            "retention_bytes": 2_147_483_648_u64,
198            "pretty_json": false,
199            "yaml": false
200        },
201        "health": {
202            "profile_threshold_policy": "shared",
203            "profile_context_bands": {
204                "agent_context": {"compact_max_tokens": 3072, "healthy_max_tokens": 8000, "warning_max_tokens": 10000},
205                "data_context": {"compact_max_tokens": 16384, "healthy_max_tokens": 65536, "warning_max_tokens": 131072}
206            },
207            "profile_queue_minimum_score": {"agent_context": 0.0, "data_context": 50.0},
208            "data_context_min_bytes": 262144,
209            "folder_bands": {
210                "compact_max_direct_tokens": 31999,
211                "healthy_max_direct_tokens": 128000,
212                "warning_max_direct_tokens": 256000,
213                "warning_max_direct_files": 17,
214                "refactor_required_max_direct_files": 37
215            },
216            "summary_top_files": 10,
217            "summary_top_folders": 10
218        },
219        "check": {
220            "fail_on_context_band": "critical",
221            "fail_on_slop_band": "critical",
222            "regression_score_delta": 5.0,
223            "fail_on_evidence_drift": false
224        }
225    })
226}
227
228fn deep_merge(base: &mut Value, override_value: Value) {
229    match (base, override_value) {
230        (Value::Object(base_map), Value::Object(override_map)) => {
231            for (key, value) in override_map {
232                if let Some(existing) = base_map.get_mut(&key) {
233                    deep_merge(existing, value);
234                } else {
235                    base_map.insert(key, value);
236                }
237            }
238        }
239        (base_slot, value) => *base_slot = value,
240    }
241}
242
243fn validate_override_shape(value: &Value, defaults: &Value, path: &str) -> Result<()> {
244    match (value, defaults) {
245        (Value::Object(values), Value::Object(defaults)) => {
246            for (key, child) in values {
247                let child_path = if path.is_empty() {
248                    key.to_string()
249                } else {
250                    format!("{path}.{key}")
251                };
252                let Some(default) = defaults.get(key) else {
253                    bail!("unknown configuration key {child_path}");
254                };
255                validate_override_shape(child, default, &child_path)?;
256            }
257        }
258        (Value::Array(values), Value::Array(defaults)) => {
259            if matches!(
260                path,
261                "verification.source_test_mappings"
262                    | "verification.path_commands"
263                    | "inventory.path_overrides"
264            ) {
265                for (index, mapping) in values.iter().enumerate() {
266                    let Some(mapping) = mapping.as_object() else {
267                        bail!("{path}[{index}] must be a mapping");
268                    };
269                    let keys: &[&str] = if path == "verification.source_test_mappings" {
270                        &["source_glob", "test_glob"]
271                    } else if path == "verification.path_commands" {
272                        &["path_glob", "command"]
273                    } else {
274                        &[
275                            "glob",
276                            "classification",
277                            "profile",
278                            "language",
279                            "verification_applicability",
280                            "generated_source_globs",
281                            "generator_command",
282                            "verification_command",
283                        ]
284                    };
285                    if mapping.keys().any(|key| !keys.contains(&key.as_str())) {
286                        bail!("{path}[{index}] contains an unsupported key");
287                    }
288                    let required: &[&str] = if path == "verification.source_test_mappings" {
289                        &["source_glob", "test_glob"]
290                    } else if path == "verification.path_commands" {
291                        &["path_glob"]
292                    } else {
293                        &["glob"]
294                    };
295                    for key in required {
296                        let Some(pattern) = mapping.get(*key).and_then(Value::as_str) else {
297                            bail!("{path}[{index}].{key} must be a string");
298                        };
299                        Glob::new(pattern).with_context(|| {
300                            format!("{path}[{index}].{key} is not a valid glob")
301                        })?;
302                    }
303                    if path == "verification.path_commands" {
304                        structured::validate_path_command(mapping, path, index)?;
305                    }
306                    if path == "inventory.path_overrides" {
307                        if !mapping.contains_key("classification")
308                            && !mapping.contains_key("profile")
309                            && !mapping.contains_key("language")
310                            && !mapping.contains_key("verification_applicability")
311                            && !mapping.contains_key("generated_source_globs")
312                            && !mapping.contains_key("generator_command")
313                            && !mapping.contains_key("verification_command")
314                        {
315                            bail!("{path}[{index}] must set at least one supported override");
316                        }
317                        if let Some(classification) =
318                            mapping.get("classification").and_then(Value::as_str)
319                        {
320                            if !Classification::is_valid(classification) {
321                                bail!("{path}[{index}].classification has an unsupported value");
322                            }
323                        }
324                        if let Some(profile) = mapping.get("profile").and_then(Value::as_str) {
325                            if !["agent_context", "data_context"].contains(&profile) {
326                                bail!("{path}[{index}].profile has an unsupported value");
327                            }
328                        }
329                        structured::validate_generated_override(mapping, path, index)?;
330                        if mapping
331                            .get("language")
332                            .is_some_and(|value| value.as_str().is_none_or(str::is_empty))
333                        {
334                            bail!("{path}[{index}].language must be a non-empty string");
335                        }
336                        if let Some(applicability) = mapping
337                            .get("verification_applicability")
338                            .and_then(Value::as_str)
339                        {
340                            if !["auto", "applicable", "not_applicable"].contains(&applicability) {
341                                bail!(
342                                    "{path}[{index}].verification_applicability has an unsupported value"
343                                );
344                            }
345                        }
346                    }
347                }
348            } else if path == "verification.commands" {
349                for (index, value) in values.iter().enumerate() {
350                    if value
351                        .as_str()
352                        .is_none_or(|command| command.trim().is_empty())
353                    {
354                        bail!("{path}[{index}] must be a non-empty string");
355                    }
356                }
357            } else if path == "inventory.ignore_globs" {
358                for (index, value) in values.iter().enumerate() {
359                    let Some(pattern) = value.as_str() else {
360                        bail!("{path}[{index}] must be a string");
361                    };
362                    Glob::new(pattern)
363                        .with_context(|| format!("{path}[{index}] is not a valid glob"))?;
364                }
365            } else if let Some(default) = defaults.first() {
366                for (index, child) in values.iter().enumerate() {
367                    validate_override_shape(child, default, &format!("{path}[{index}]"))?;
368                }
369            } else if !values.is_empty() {
370                bail!("{path} does not accept configured entries");
371            }
372        }
373        (Value::Number(_), Value::Number(_))
374        | (Value::String(_), Value::String(_))
375        | (Value::Bool(_), Value::Bool(_))
376        | (Value::Null, Value::Null) => {}
377        _ => bail!("configuration key {path} has the wrong type"),
378    }
379    Ok(())
380}
381
382fn require_positive(config: &Value, pointer: &str) -> Result<()> {
383    if config.pointer(pointer).and_then(Value::as_u64).unwrap_or(0) == 0 {
384        bail!(
385            "{} must be a positive integer",
386            pointer.trim_start_matches('/').replace('/', ".")
387        );
388    }
389    Ok(())
390}
391
392pub fn validate(config: &Value) -> Result<()> {
393    for pointer in [
394        "/tokenization/context_bands/compact_max_tokens",
395        "/tokenization/context_bands/healthy_max_tokens",
396        "/tokenization/context_bands/warning_max_tokens",
397        "/history/churn_window_days",
398        "/history/age_half_life_days",
399        "/history/max_commits",
400        "/organization/candidate_file_limit",
401        "/organization/min_file_tokens",
402        "/organization/max_file_tokens",
403        "/organization/shingle_size",
404        "/organization/window_step",
405        "/organization/max_pairs_per_file",
406        "/organization/max_temporal_edges",
407        "/organization/max_commit_files",
408        "/organization/min_cochange_support",
409        "/navigation/top_distinctive_terms",
410        "/semantic_drift/top_term_limit",
411        "/resources/memory_budget_mb",
412        "/resources/large_file_bytes",
413        "/resources/cache_max_bytes",
414        "/resources/cache_max_entries",
415        "/output/retention_runs",
416        "/output/retention_bytes",
417        "/health/data_context_min_bytes",
418        "/health/folder_bands/compact_max_direct_tokens",
419        "/health/folder_bands/healthy_max_direct_tokens",
420        "/health/folder_bands/warning_max_direct_tokens",
421        "/health/folder_bands/warning_max_direct_files",
422        "/health/folder_bands/refactor_required_max_direct_files",
423        "/health/summary_top_files",
424        "/health/summary_top_folders",
425    ] {
426        require_positive(config, pointer)?;
427    }
428    for profile in ["agent_context", "data_context"] {
429        let pointer = format!("/health/profile_context_bands/{profile}");
430        let bands = config.pointer(&pointer).unwrap_or(&Value::Null);
431        let compact = bands["compact_max_tokens"].as_u64().unwrap_or_default();
432        let healthy = bands["healthy_max_tokens"].as_u64().unwrap_or_default();
433        let warning = bands["warning_max_tokens"].as_u64().unwrap_or_default();
434        if !(compact > 0 && compact < healthy && healthy < warning) {
435            bail!(
436                "health.profile_context_bands.{profile} must be positive and strictly increasing"
437            );
438        }
439        let minimum_score = config
440            .pointer(&format!("/health/profile_queue_minimum_score/{profile}"))
441            .and_then(Value::as_f64)
442            .unwrap_or(-1.0);
443        if !(0.0..=100.0).contains(&minimum_score) {
444            bail!("health.profile_queue_minimum_score.{profile} must be between 0 and 100");
445        }
446    }
447    let bands = &config["tokenization"]["context_bands"];
448    let compact = bands["compact_max_tokens"].as_u64().unwrap_or_default();
449    let healthy = bands["healthy_max_tokens"].as_u64().unwrap_or_default();
450    let warning = bands["warning_max_tokens"].as_u64().unwrap_or_default();
451    if !(compact < healthy && healthy < warning) {
452        bail!("tokenization.context_bands must be strictly increasing");
453    }
454    let folder_bands = &config["health"]["folder_bands"];
455    let folder_compact = folder_bands["compact_max_direct_tokens"]
456        .as_u64()
457        .unwrap_or_default();
458    let folder_healthy = folder_bands["healthy_max_direct_tokens"]
459        .as_u64()
460        .unwrap_or_default();
461    let folder_warning = folder_bands["warning_max_direct_tokens"]
462        .as_u64()
463        .unwrap_or_default();
464    let folder_warning_files = folder_bands["warning_max_direct_files"]
465        .as_u64()
466        .unwrap_or_default();
467    let folder_refactor_files = folder_bands["refactor_required_max_direct_files"]
468        .as_u64()
469        .unwrap_or_default();
470    if !(folder_compact < folder_healthy && folder_healthy < folder_warning) {
471        bail!("health.folder_bands direct-token thresholds must be strictly increasing");
472    }
473    if folder_warning_files >= folder_refactor_files {
474        bail!("health.folder_bands direct-file thresholds must be strictly increasing");
475    }
476    let min_tokens = config["organization"]["min_file_tokens"]
477        .as_u64()
478        .unwrap_or_default();
479    let max_tokens = config["organization"]["max_file_tokens"]
480        .as_u64()
481        .unwrap_or_default();
482    if min_tokens > max_tokens {
483        bail!("organization.min_file_tokens must not exceed max_file_tokens");
484    }
485    for pointer in [
486        "/organization/min_similarity",
487        "/organization/min_coupling_lift",
488        "/check/regression_score_delta",
489    ] {
490        let Some(value) = config.pointer(pointer).and_then(Value::as_f64) else {
491            bail!(
492                "{} must be a number",
493                pointer.trim_start_matches('/').replace('/', ".")
494            );
495        };
496        if !value.is_finite() || value < 0.0 {
497            bail!(
498                "{} must be finite and non-negative",
499                pointer.trim_start_matches('/').replace('/', ".")
500            );
501        }
502        if pointer == "/organization/min_similarity" && value > 1.0 {
503            bail!("organization.min_similarity must be at most 1.0");
504        }
505    }
506    let weights = ["context_weight", "age_weight", "churn_weight"]
507        .into_iter()
508        .map(|key| config["scoring"][key].as_f64().unwrap_or(f64::NAN))
509        .collect::<Vec<_>>();
510    if weights
511        .iter()
512        .any(|value| !value.is_finite() || *value < 0.0)
513        || (weights.iter().sum::<f64>() - 1.0).abs() > 1e-9
514    {
515        bail!("scoring weights must be finite, non-negative, and sum to 1.0");
516    }
517    for (pointer, allowed) in [
518        (
519            "/check/fail_on_context_band",
520            &["compact", "healthy", "warning", "critical"][..],
521        ),
522        (
523            "/check/fail_on_slop_band",
524            &["low", "moderate", "high", "critical"][..],
525        ),
526    ] {
527        let value = config
528            .pointer(pointer)
529            .and_then(Value::as_str)
530            .unwrap_or_default();
531        if !allowed.contains(&value) {
532            bail!(
533                "{} has unsupported value {value:?}",
534                pointer.trim_start_matches('/').replace('/', ".")
535            );
536        }
537    }
538    Ok(())
539}
540
541fn normalize_legacy(mut payload: Value) -> Result<Value> {
542    let Some(object) = payload.as_object_mut() else {
543        bail!("config.yaml must decode to a mapping.");
544    };
545    let schema = object
546        .get("schema_version")
547        .and_then(Value::as_u64)
548        .unwrap_or(1);
549    if schema != 1 && schema != 2 {
550        bail!("config.yaml must declare schema_version: 1 or schema_version: 2.");
551    }
552    if schema == 1 {
553        object.insert("schema_version".into(), json!(2));
554        let tokenizer_name = object
555            .remove("tokenizer")
556            .and_then(|item| item.get("name").cloned());
557        let legacy_bands = object.remove("context_bands");
558        if tokenizer_name.is_some() || legacy_bands.is_some() {
559            let tokenization = object.entry("tokenization").or_insert_with(|| json!({}));
560            let Some(tokenization) = tokenization.as_object_mut() else {
561                bail!("tokenization must be a mapping.");
562            };
563            if let Some(name) = tokenizer_name {
564                tokenization.entry("context_tokenizer_name").or_insert(name);
565            }
566            if let Some(bands) = legacy_bands {
567                tokenization.entry("context_bands").or_insert(bands);
568            }
569        }
570    }
571    if let Some(check) = object.get_mut("check").and_then(Value::as_object_mut) {
572        let legacy = check.remove("fail_on_priority_band");
573        if !check.contains_key("fail_on_slop_band") {
574            if let Some(legacy) = legacy {
575                let mapped = match legacy.as_str().unwrap_or_default() {
576                    "watchlist" => "low",
577                    "needs_refactor" => "moderate",
578                    "should_refactor" => "high",
579                    "must_refactor" => "critical",
580                    other => other,
581                };
582                check.insert("fail_on_slop_band".into(), json!(mapped));
583            }
584        }
585    }
586    Ok(payload)
587}
588
589pub fn load(repo_root: &Path) -> Result<Value> {
590    let path = config_path(repo_root);
591    if !path.exists() {
592        return Ok(default_config());
593    }
594    let source =
595        fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?;
596    let yaml: serde_yaml::Value =
597        serde_yaml::from_str(&source).with_context(|| format!("invalid {}", path.display()))?;
598    let override_value =
599        serde_json::to_value(yaml).context("config.yaml contains unsupported YAML values")?;
600    let override_value = normalize_legacy(override_value)?;
601    validate_override_shape(&override_value, &default_config(), "")?;
602    let mut merged = default_config();
603    deep_merge(&mut merged, override_value);
604    validate(&merged)?;
605    Ok(merged)
606}
607
608pub(crate) fn effective_from_override(override_value: Value) -> Result<Value> {
609    let override_value = normalize_legacy(override_value)?;
610    validate_override_shape(&override_value, &default_config(), "")?;
611    let mut merged = default_config();
612    deep_merge(&mut merged, override_value);
613    validate(&merged)?;
614    Ok(merged)
615}
616
617fn schema_for_value(value: &Value, path: &str) -> Value {
618    match value {
619        Value::Object(values) => {
620            let properties = values
621                .iter()
622                .map(|(key, value)| {
623                    let child = if path.is_empty() {
624                        key.clone()
625                    } else {
626                        format!("{path}.{key}")
627                    };
628                    (key.clone(), schema_for_value(value, &child))
629                })
630                .collect::<Map<String, Value>>();
631            json!({
632                "type": "object",
633                "description": format!("Git Slop {path} configuration."),
634                "additionalProperties": false,
635                "properties": properties
636            })
637        }
638        Value::Array(values) => {
639            let items = if path == "verification.commands" {
640                json!({"type": "string", "minLength": 1})
641            } else if path == "verification.source_test_mappings" {
642                json!({
643                    "type": "object",
644                    "additionalProperties": false,
645                    "required": ["source_glob", "test_glob"],
646                    "properties": {
647                        "source_glob": {"type": "string", "minLength": 1, "description": "Source-path glob."},
648                        "test_glob": {"type": "string", "minLength": 1, "description": "Test-path glob."}
649                    }
650                })
651            } else if path == "verification.path_commands" {
652                structured::path_command_schema()
653            } else if path == "inventory.path_overrides" {
654                structured::path_override_schema()
655            } else {
656                values.first().map_or_else(
657                    || json!({}),
658                    |value| schema_for_value(value, &format!("{path}[]")),
659                )
660            };
661            json!({"type": "array", "default": value, "items": items})
662        }
663        Value::String(default) => {
664            let mut schema = json!({"type": "string", "default": default});
665            let allowed: Option<&[&str]> = match path {
666                "tokenization.context_tokenizer_name" => Some(&[
667                    "cl100k_base",
668                    "o200k_base",
669                    "o200k_harmony",
670                    "p50k_base",
671                    "p50k_edit",
672                    "r50k_base",
673                ]),
674                "check.fail_on_context_band" => {
675                    Some(&["compact", "healthy", "warning", "critical"])
676                }
677                "check.fail_on_slop_band" => Some(&["low", "moderate", "high", "critical"]),
678                "health.profile_threshold_policy" => Some(&["shared", "per_profile"]),
679                _ => None,
680            };
681            if let Some(allowed) = allowed {
682                schema["enum"] = json!(allowed);
683            }
684            schema
685        }
686        Value::Number(default) => {
687            let minimum = if path.starts_with("scoring.")
688                || path.starts_with("health.profile_queue_minimum_score.")
689                || matches!(
690                    path,
691                    "organization.min_similarity"
692                        | "organization.min_coupling_lift"
693                        | "check.regression_score_delta"
694                ) {
695                0
696            } else {
697                1
698            };
699            let mut schema = json!({
700                "type": if default.is_u64() { "integer" } else { "number" },
701                "default": default,
702                "minimum": minimum
703            });
704            if matches!(path, "organization.min_similarity") {
705                schema["maximum"] = json!(1.0);
706            }
707            if path.starts_with("health.profile_queue_minimum_score.") {
708                schema["maximum"] = json!(100.0);
709            }
710            schema
711        }
712        Value::Bool(default) => json!({"type": "boolean", "default": default}),
713        Value::Null => json!({"type": "null"}),
714    }
715}
716
717pub fn schema() -> Value {
718    let defaults = default_config();
719    let mut schema = schema_for_value(&defaults, "");
720    schema["$schema"] = json!("https://json-schema.org/draft/2020-12/schema");
721    schema["$id"] =
722        json!("https://github.com/coreycoto/git-slop/blob/v0.11.6/schemas/config-2.json");
723    schema["title"] = json!("Git Slop configuration schema 2");
724    schema["required"] = json!(["schema_version"]);
725    schema["properties"]["schema_version"] = json!({
726        "type": "integer",
727        "const": 2,
728        "default": 2,
729        "description": "Configuration contract version. Schema 1 is accepted only as migration input."
730    });
731    schema["x-git-slop-deprecated-keys"] = json!({
732        "tokenizer": "Use tokenization.context_tokenizer_name.",
733        "context_bands": "Use tokenization.context_bands.",
734        "check.fail_on_priority_band": "Use check.fail_on_slop_band."
735    });
736    schema
737}
738
739pub fn ensure_state_dirs(repo_root: &Path) -> Result<()> {
740    for path in [
741        slop_dir(repo_root),
742        latest_dir(repo_root),
743        runs_dir(repo_root),
744        cache_dir(repo_root),
745    ] {
746        fs::create_dir_all(&path)
747            .with_context(|| format!("failed to create {}", path.display()))?;
748    }
749    Ok(())
750}
751
752pub fn pointer_u64(value: &Value, pointer: &str, default: u64) -> u64 {
753    value
754        .pointer(pointer)
755        .and_then(Value::as_u64)
756        .unwrap_or(default)
757}
758
759pub fn pointer_f64(value: &Value, pointer: &str, default: f64) -> f64 {
760    value
761        .pointer(pointer)
762        .and_then(Value::as_f64)
763        .unwrap_or(default)
764}
765
766pub fn pointer_bool(value: &Value, pointer: &str, default: bool) -> bool {
767    value
768        .pointer(pointer)
769        .and_then(Value::as_bool)
770        .unwrap_or(default)
771}
772
773pub fn pointer_str<'a>(value: &'a Value, pointer: &str) -> Option<&'a str> {
774    value.pointer(pointer).and_then(Value::as_str)
775}
776
777pub fn pointer_strings(value: &Value, pointer: &str) -> Vec<String> {
778    value
779        .pointer(pointer)
780        .and_then(Value::as_array)
781        .into_iter()
782        .flatten()
783        .filter_map(Value::as_str)
784        .map(ToOwned::to_owned)
785        .collect()
786}
787
788#[cfg(test)]
789mod tests {
790    use std::fs;
791    use std::process::Command;
792
793    use serde_json::{Value, json};
794    use tempfile::tempdir;
795
796    use super::{config_path, default_config, load};
797
798    fn load_payload(payload: Value) -> Value {
799        let repository = tempdir().expect("temporary repository");
800        let path = config_path(repository.path());
801        fs::create_dir_all(path.parent().expect("config parent")).expect("config directory");
802        fs::write(
803            &path,
804            serde_yaml::to_string(&payload).expect("serialize config"),
805        )
806        .expect("write config");
807        load(repository.path()).expect("load config")
808    }
809
810    #[test]
811    fn default_config_uses_the_schema_two_contract() {
812        let config = default_config();
813
814        assert_eq!(config["schema_version"], 2);
815        assert_eq!(config["check"]["fail_on_slop_band"], "critical");
816        assert!(config["check"].get("fail_on_priority_band").is_none());
817        for section in ["tokenization", "organization", "verification"] {
818            assert!(config.get(section).is_some(), "missing default {section}");
819        }
820    }
821
822    #[test]
823    fn schema_one_payload_defaults_and_aliases_are_normalized_to_schema_two() {
824        let normalized = load_payload(json!({
825            "tokenizer": {"name": "r50k_base"},
826            "context_bands": {"warning_max_tokens": 9_000},
827            "history": {"follow_renames": true}
828        }));
829
830        assert_eq!(normalized["schema_version"], 2);
831        assert_eq!(
832            normalized["tokenization"]["context_tokenizer_name"],
833            "r50k_base"
834        );
835        assert_eq!(
836            normalized["tokenization"]["context_bands"]["warning_max_tokens"],
837            9_000
838        );
839        assert_eq!(
840            normalized["tokenization"]["context_bands"]["compact_max_tokens"],
841            3_072
842        );
843        assert_eq!(normalized["history"]["follow_renames"], true);
844        assert!(normalized.get("tokenizer").is_none());
845        assert!(normalized.get("context_bands").is_none());
846    }
847
848    #[test]
849    fn every_legacy_priority_band_maps_to_its_slop_band() {
850        for (legacy, expected) in [
851            ("watchlist", "low"),
852            ("needs_refactor", "moderate"),
853            ("should_refactor", "high"),
854            ("must_refactor", "critical"),
855        ] {
856            let normalized = load_payload(json!({
857                "schema_version": 2,
858                "check": {"fail_on_priority_band": legacy}
859            }));
860
861            assert_eq!(normalized["check"]["fail_on_slop_band"], expected);
862            assert!(
863                normalized["check"].get("fail_on_priority_band").is_none(),
864                "legacy key survived normalization for {legacy}"
865            );
866        }
867    }
868
869    #[test]
870    fn new_slop_band_wins_and_the_legacy_key_is_always_removed() {
871        let normalized = load_payload(json!({
872            "schema_version": 2,
873            "check": {
874                "fail_on_priority_band": "must_refactor",
875                "fail_on_slop_band": "moderate"
876            }
877        }));
878
879        assert_eq!(normalized["check"]["fail_on_slop_band"], "moderate");
880        assert!(normalized["check"].get("fail_on_priority_band").is_none());
881    }
882
883    #[test]
884    fn strict_validation_rejects_unknown_keys_wrong_types_ranges_and_weights() {
885        for (payload, expected) in [
886            (
887                json!({"schema_version": 2, "mystery": true}),
888                "unknown configuration key mystery",
889            ),
890            (
891                json!({"schema_version": 2, "history": {"churn_window_days": "many"}}),
892                "wrong type",
893            ),
894            (
895                json!({"schema_version": 2, "tokenization": {"context_bands": {"compact_max_tokens": 9000, "healthy_max_tokens": 8000}}}),
896                "strictly increasing",
897            ),
898            (
899                json!({"schema_version": 2, "organization": {"min_similarity": -1.0}}),
900                "non-negative",
901            ),
902            (
903                json!({"schema_version": 2, "scoring": {"context_weight": 0.9}}),
904                "sum to 1.0",
905            ),
906        ] {
907            let repository = tempdir().expect("temporary repository");
908            let path = config_path(repository.path());
909            fs::create_dir_all(path.parent().expect("config parent")).expect("config directory");
910            fs::write(
911                &path,
912                serde_yaml::to_string(&payload).expect("serialize config"),
913            )
914            .expect("write config");
915            let error = load(repository.path()).expect_err("invalid config must fail closed");
916            assert!(error.to_string().contains(expected), "{error:#}");
917        }
918    }
919
920    #[test]
921    fn strict_validation_rejects_invalid_globs_and_non_monotonic_folder_bands() {
922        for (payload, expected) in [
923            (
924                json!({"schema_version": 2, "inventory": {"ignore_globs": ["["]}}),
925                "valid glob",
926            ),
927            (
928                json!({"schema_version": 2, "health": {"folder_bands": {"compact_max_direct_tokens": 200000}}}),
929                "strictly increasing",
930            ),
931        ] {
932            let repository = tempdir().expect("temporary repository");
933            let path = config_path(repository.path());
934            fs::create_dir_all(path.parent().expect("config parent")).expect("config directory");
935            fs::write(&path, serde_yaml::to_string(&payload).expect("config YAML"))
936                .expect("config");
937            let error = load(repository.path()).expect_err("invalid config");
938            assert!(error.to_string().contains(expected), "{error:#}");
939        }
940    }
941
942    #[test]
943    fn scan_lock_is_process_exclusive_and_reusable() {
944        let repository = tempdir().expect("temporary repository");
945        assert!(
946            Command::new("git")
947                .args(["init", "--quiet"])
948                .current_dir(repository.path())
949                .status()
950                .expect("git init")
951                .success()
952        );
953        let state = repository.path().join("state-a");
954        let first = super::acquire_scan_lock(&state).expect("first lock");
955        let error = super::acquire_scan_lock(&state).unwrap_err().to_string();
956        assert!(error.contains("scan.lock"), "{error}");
957        super::acquire_scan_lock(&repository.path().join("state-b")).expect("parallel state lock");
958        drop(first);
959        super::acquire_scan_lock(&state).expect("reacquired lock");
960    }
961
962    #[test]
963    fn generated_schema_describes_real_nested_defaults_and_bounds() {
964        let schema = super::schema();
965        assert_eq!(schema["properties"]["schema_version"]["const"], 2);
966        assert_eq!(
967            schema["properties"]["organization"]["properties"]["min_similarity"]["maximum"],
968            1.0
969        );
970        assert_eq!(
971            schema["properties"]["check"]["properties"]["fail_on_slop_band"]["enum"][3],
972            "critical"
973        );
974        let published: Value = serde_json::from_str(include_str!("../schemas/config-2.json"))
975            .expect("published config schema");
976        assert_eq!(published, schema);
977    }
978}