1use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9use std::path::Path;
10
11#[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#[derive(Debug, Clone, Serialize, Deserialize)]
45#[serde(default)]
46pub struct Thresholds {
47 pub fail_on: String,
49 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#[derive(Debug, Clone, Serialize, Deserialize, Default)]
67#[serde(default)]
68pub struct RulesConfig {
69 pub disabled: Vec<String>,
71 pub table_overrides: HashMap<String, TableOverride>,
73}
74
75#[derive(Debug, Clone, Serialize, Deserialize, Default)]
76#[serde(default)]
77pub struct TableOverride {
78 pub max_risk: Option<String>,
80 pub ignored: bool,
82}
83
84#[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 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#[derive(Debug, Clone, Serialize, Deserialize)]
128#[serde(default)]
129pub struct GuardConfig {
130 pub require_typed_confirmation: bool,
132 pub audit_log: String,
134 pub block_agents: bool,
136 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#[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
175pub 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
216pub 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#[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}