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
43pub 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
88pub 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}