Skip to main content

schema_risk/
config.rs

1//! Configuration loader for `schema-risk.yml` / `schema-risk.yaml`.
2//!
3//! Loads per-project configuration from `schema-risk.yml` in the current
4//! directory (or a path supplied via `--config`). Falls back gracefully to
5//! built-in defaults when the file is absent.
6
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9use std::path::Path;
10
11// ─────────────────────────────────────────────
12// Top-level config struct
13// ─────────────────────────────────────────────
14
15/// Root configuration loaded from `schema-risk.yml`.
16#[derive(Debug, Clone, Serialize, Deserialize)]
17#[serde(default)]
18pub struct Config {
19    pub version: u32,
20    pub thresholds: Thresholds,
21    pub rules: RulesConfig,
22    pub scan: ScanConfig,
23    pub guard: GuardConfig,
24    pub output: OutputConfig,
25}
26
27impl Default for Config {
28    fn default() -> Self {
29        Self {
30            version: 2,
31            thresholds: Thresholds::default(),
32            rules: RulesConfig::default(),
33            scan: ScanConfig::default(),
34            guard: GuardConfig::default(),
35            output: OutputConfig::default(),
36        }
37    }
38}
39
40// ─────────────────────────────────────────────
41// Thresholds
42// ─────────────────────────────────────────────
43
44#[derive(Debug, Clone, Serialize, Deserialize)]
45#[serde(default)]
46pub struct Thresholds {
47    /// Exit non-zero if any migration reaches this risk level.
48    pub fail_on: String,
49    /// Show guard prompt starting at this risk level.
50    pub guard_on: String,
51}
52
53impl Default for Thresholds {
54    fn default() -> Self {
55        Self {
56            fail_on: "high".to_string(),
57            guard_on: "medium".to_string(),
58        }
59    }
60}
61
62// ─────────────────────────────────────────────
63// Rules config
64// ─────────────────────────────────────────────
65
66#[derive(Debug, Clone, Serialize, Deserialize, Default)]
67#[serde(default)]
68pub struct RulesConfig {
69    /// Rule IDs to disable (e.g. ["R03", "R07"])
70    pub disabled: Vec<String>,
71    /// Per-table risk overrides.
72    pub table_overrides: HashMap<String, TableOverride>,
73}
74
75#[derive(Debug, Clone, Serialize, Deserialize, Default)]
76#[serde(default)]
77pub struct TableOverride {
78    /// Allow higher risk level on this table.
79    pub max_risk: Option<String>,
80    /// Skip risk analysis entirely for this table.
81    pub ignored: bool,
82}
83
84// ─────────────────────────────────────────────
85// Scan config
86// ─────────────────────────────────────────────
87
88#[derive(Debug, Clone, Serialize, Deserialize)]
89#[serde(default)]
90pub struct ScanConfig {
91    pub root_dir: String,
92    pub extensions: Vec<String>,
93    pub exclude: Vec<String>,
94    /// Skip columns/tables with fewer than 4 characters (avoids false positives).
95    pub skip_short_identifiers: bool,
96}
97
98impl Default for ScanConfig {
99    fn default() -> Self {
100        Self {
101            root_dir: ".".to_string(),
102            extensions: vec![
103                "rs".to_string(),
104                "py".to_string(),
105                "go".to_string(),
106                "ts".to_string(),
107                "js".to_string(),
108                "rb".to_string(),
109                "java".to_string(),
110                "kt".to_string(),
111            ],
112            exclude: vec![
113                "target/".to_string(),
114                "node_modules/".to_string(),
115                "vendor/".to_string(),
116                ".git/".to_string(),
117            ],
118            skip_short_identifiers: true,
119        }
120    }
121}
122
123// ─────────────────────────────────────────────
124// Guard config
125// ─────────────────────────────────────────────
126
127#[derive(Debug, Clone, Serialize, Deserialize)]
128#[serde(default)]
129pub struct GuardConfig {
130    /// Require full phrase "yes I am sure" for Critical operations.
131    pub require_typed_confirmation: bool,
132    /// Path to write the audit log JSON.
133    pub audit_log: String,
134    /// Always exit 4 when actor is detected as an AI agent.
135    pub block_agents: bool,
136    /// Exit 4 for CI pipelines (default: false - just print warning).
137    pub block_ci: bool,
138}
139
140impl Default for GuardConfig {
141    fn default() -> Self {
142        Self {
143            require_typed_confirmation: true,
144            audit_log: ".schemarisk-audit.json".to_string(),
145            block_agents: true,
146            block_ci: false,
147        }
148    }
149}
150
151// ─────────────────────────────────────────────
152// Output config
153// ─────────────────────────────────────────────
154
155#[derive(Debug, Clone, Serialize, Deserialize)]
156#[serde(default)]
157pub struct OutputConfig {
158    pub format: String,
159    pub color: bool,
160    pub show_recommendations: bool,
161    pub show_impact: bool,
162}
163
164impl Default for OutputConfig {
165    fn default() -> Self {
166        Self {
167            format: "terminal".to_string(),
168            color: true,
169            show_recommendations: true,
170            show_impact: true,
171        }
172    }
173}
174
175// ─────────────────────────────────────────────
176// Loader
177// ─────────────────────────────────────────────
178
179/// Load configuration from a file path, falling back to defaults if absent.
180///
181/// Searches in order:
182/// 1. The path supplied via `--config <PATH>`
183/// 2. `./schema-risk.yml`
184/// 3. `./schema-risk.yaml`
185/// 4. Built-in defaults
186pub fn load(path: Option<&str>) -> Config {
187    let candidates: Vec<&str> = if let Some(p) = path {
188        vec![p]
189    } else {
190        vec!["schema-risk.yml", "schema-risk.yaml"]
191    };
192
193    for candidate in &candidates {
194        if let Some(config) = try_load(Path::new(candidate)) {
195            return config;
196        }
197    }
198
199    Config::default()
200}
201
202fn try_load(path: &Path) -> Option<Config> {
203    if !path.exists() {
204        return None;
205    }
206    let contents = std::fs::read_to_string(path).ok()?;
207    match serde_yaml::from_str::<Config>(&contents) {
208        Ok(c) => Some(c),
209        Err(e) => {
210            eprintln!("warning: Failed to parse {}: {e}", path.display());
211            None
212        }
213    }
214}
215
216/// Return the canonical YAML template written by `schemarisk init`.
217pub fn default_yaml_template() -> &'static str {
218    r#"# schema-risk.yml — per-project SchemaRisk configuration
219version: 2
220
221thresholds:
222  fail_on: high          # low | medium | high | critical
223  guard_on: medium       # operations at this level or above trigger guard prompts
224
225rules:
226  # Disable specific rules by ID
227  disabled: []           # e.g. [R03, R07]
228
229  # Per-table overrides
230  table_overrides:
231    audit_log:
232      max_risk: critical   # allow higher risk on this table (it's append-only)
233    sessions:
234      ignored: true        # skip risk analysis for this table entirely
235
236scan:
237  root_dir: "."            # directory to scan for code impact
238  extensions: [rs, py, go, ts, js, rb, java, kt]
239  exclude: [target/, node_modules/, vendor/, .git/]
240  skip_short_identifiers: true   # skip columns < 4 chars (avoids false positives)
241
242guard:
243  require_typed_confirmation: true   # require "yes I am sure" for Critical ops
244  audit_log: ".schemarisk-audit.json"
245  block_agents: true                 # always block when AGENT actor detected
246  block_ci: false                    # set true to block CI pipelines too
247
248output:
249  format: terminal        # terminal | json | markdown | sarif
250  color: true
251  show_recommendations: true
252  show_impact: true
253"#
254}
255
256// ─────────────────────────────────────────────
257// Tests
258// ─────────────────────────────────────────────
259
260#[cfg(test)]
261mod tests {
262    use super::*;
263
264    #[test]
265    fn default_config_has_sensible_values() {
266        let cfg = Config::default();
267        assert_eq!(cfg.thresholds.fail_on, "high");
268        assert_eq!(cfg.thresholds.guard_on, "medium");
269        assert!(cfg.guard.block_agents);
270        assert!(!cfg.guard.block_ci);
271        assert!(cfg.scan.skip_short_identifiers);
272    }
273
274    #[test]
275    fn yaml_template_parses_correctly() {
276        let cfg: Config =
277            serde_yaml::from_str(default_yaml_template()).expect("template should be valid YAML");
278        assert_eq!(cfg.version, 2);
279        assert_eq!(cfg.thresholds.fail_on, "high");
280    }
281}