1use anyhow::{Context, Result, anyhow};
12use sqry_core::config::{
13 graph_config_persistence::{ConfigPersistence, LoadReport},
14 graph_config_schema::{AliasEntry, GraphConfigFile},
15 graph_config_store::GraphConfigStore,
16};
17use std::io::{self, BufRead};
18use std::path::Path;
19
20const KB_BYTES: u64 = 1024;
21const MB_BYTES: u64 = KB_BYTES * 1024;
22const GB_BYTES: u64 = MB_BYTES * 1024;
23const KB_BYTES_F64: f64 = 1024.0;
24const MB_BYTES_F64: f64 = 1024.0 * 1024.0;
25const GB_BYTES_F64: f64 = 1024.0 * 1024.0 * 1024.0;
26
27pub fn run_config_init(path: Option<&str>, force: bool) -> Result<()> {
37 let project_root = Path::new(path.unwrap_or("."));
38 let store = GraphConfigStore::new(project_root).context("Failed to create config store")?;
39
40 if store.is_initialized() && !force {
42 anyhow::bail!(
43 "Config already initialized at {}. Use --force to overwrite.",
44 store.paths().config_file().display()
45 );
46 }
47
48 store
50 .validate(false)
51 .context("Filesystem validation failed")?;
52
53 let persistence = ConfigPersistence::new(&store);
55 let config = persistence
56 .init(5000, "cli")
57 .context("Failed to initialize config")?;
58
59 println!(
60 "✓ Config initialized at {}",
61 store.paths().config_file().display()
62 );
63 println!(" Schema version: {}", config.schema_version);
64 println!(" Created at: {}", config.metadata.created_at);
65
66 Ok(())
67}
68
69pub fn run_config_show(path: Option<&str>, json: bool, key: Option<&str>) -> Result<()> {
75 let project_root = Path::new(path.unwrap_or("."));
76 let store = GraphConfigStore::new(project_root).context("Failed to create config store")?;
77
78 if !store.is_initialized() {
79 anyhow::bail!("Config not initialized. Run 'sqry config init' first.");
80 }
81
82 let persistence = ConfigPersistence::new(&store);
83 let (config, report) = persistence.load().context("Failed to load config")?;
84
85 print_config_diagnostics(&report);
86
87 if let Some(key_path) = key {
89 return show_config_key(&config, key_path, json);
90 }
91
92 if json {
94 let json_str =
95 serde_json::to_string_pretty(&config).context("Failed to serialize config")?;
96 println!("{json_str}");
97 } else {
98 print_config_human(&store, &config, &report);
99 }
100
101 Ok(())
102}
103
104fn print_config_diagnostics(report: &LoadReport) {
105 for warning in &report.warnings {
106 eprintln!("Warning: {warning}");
107 }
108
109 for action in &report.recovery_actions {
110 eprintln!("Recovery: {action}");
111 }
112}
113
114#[allow(clippy::too_many_lines)] fn print_config_human(store: &GraphConfigStore, config: &GraphConfigFile, report: &LoadReport) {
116 println!("Config file: {}", store.paths().config_file().display());
117 println!("Schema version: {}", config.schema_version);
118 println!("Integrity: {:?}", report.integrity_status);
119 println!();
120
121 println!("=== Metadata ===");
122 println!("Created at: {}", config.metadata.created_at);
123 println!("Updated at: {}", config.metadata.updated_at);
124 println!("sqry version: {}", config.metadata.written_by.sqry_version);
125 println!();
126
127 println!("=== Limits ===");
128 println!(
129 "max_results: {}",
130 if config.config.limits.max_results == 0 {
131 "unlimited".to_string()
132 } else {
133 config.config.limits.max_results.to_string()
134 }
135 );
136 println!(
137 "max_depth: {}",
138 if config.config.limits.max_depth == 0 {
139 "unlimited".to_string()
140 } else {
141 config.config.limits.max_depth.to_string()
142 }
143 );
144 println!(
145 "max_bytes_per_file: {}",
146 if config.config.limits.max_bytes_per_file == 0 {
147 "unlimited".to_string()
148 } else {
149 format_bytes(config.config.limits.max_bytes_per_file)
150 }
151 );
152 println!(
153 "max_files: {}",
154 if config.config.limits.max_files == 0 {
155 "unlimited".to_string()
156 } else {
157 config.config.limits.max_files.to_string()
158 }
159 );
160 println!();
161
162 println!("=== Analysis ===");
163 println!(
164 "analysis_label_budget_per_kind: {}",
165 if config.config.limits.analysis_label_budget_per_kind == 0 {
166 "unlimited".to_string()
167 } else {
168 config
169 .config
170 .limits
171 .analysis_label_budget_per_kind
172 .to_string()
173 }
174 );
175 println!(
176 "analysis_density_gate_threshold: {}",
177 if config.config.limits.analysis_density_gate_threshold == 0 {
178 "disabled".to_string()
179 } else {
180 config
181 .config
182 .limits
183 .analysis_density_gate_threshold
184 .to_string()
185 }
186 );
187 println!(
188 "analysis_budget_exceeded_policy: {}",
189 config.config.limits.analysis_budget_exceeded_policy
190 );
191 println!();
192
193 println!("=== Locking ===");
194 println!(
195 "write_lock_timeout_ms: {}",
196 config.config.locking.write_lock_timeout_ms
197 );
198 println!(
199 "stale_lock_timeout_ms: {}",
200 config.config.locking.stale_lock_timeout_ms
201 );
202 println!(
203 "stale_takeover_policy: {}",
204 config.config.locking.stale_takeover_policy
205 );
206 println!();
207
208 println!("=== Output ===");
209 println!(
210 "default_pagination: {}",
211 config.config.output.default_pagination
212 );
213 println!("page_size: {}", config.config.output.page_size);
214 println!(
215 "max_preview_bytes: {}",
216 format_bytes(config.config.output.max_preview_bytes)
217 );
218 println!();
219
220 println!("=== Parallelism ===");
221 println!(
222 "max_threads: {}",
223 if config.config.parallelism.max_threads == 0 {
224 "auto-detect".to_string()
225 } else {
226 config.config.parallelism.max_threads.to_string()
227 }
228 );
229 println!();
230
231 println!("=== Aliases ({}) ===", config.config.aliases.len());
232 for (name, alias) in &config.config.aliases {
233 println!(" {}: {}", name, alias.query);
234 if let Some(desc) = &alias.description {
235 println!(" Description: {desc}");
236 }
237 }
238}
239
240fn show_config_key(config: &GraphConfigFile, key_path: &str, json: bool) -> Result<()> {
242 let parts: Vec<&str> = key_path.split('.').collect();
244
245 if parts.is_empty() {
246 anyhow::bail!("Invalid key path: {key_path}");
247 }
248
249 let value = match parts[0] {
251 "limits" => match parts.get(1) {
252 Some(&"max_results") => serde_json::to_value(config.config.limits.max_results)?,
253 Some(&"max_depth") => serde_json::to_value(config.config.limits.max_depth)?,
254 Some(&"max_bytes_per_file") => {
255 serde_json::to_value(config.config.limits.max_bytes_per_file)?
256 }
257 Some(&"max_files") => serde_json::to_value(config.config.limits.max_files)?,
258 Some(&"analysis_label_budget_per_kind") => {
259 serde_json::to_value(config.config.limits.analysis_label_budget_per_kind)?
260 }
261 Some(&"analysis_density_gate_threshold") => {
262 serde_json::to_value(config.config.limits.analysis_density_gate_threshold)?
263 }
264 Some(&"analysis_budget_exceeded_policy") => {
265 serde_json::to_value(&config.config.limits.analysis_budget_exceeded_policy)?
266 }
267 _ => anyhow::bail!("Unknown limits key: {:?}", parts.get(1)),
268 },
269 "locking" => match parts.get(1) {
270 Some(&"write_lock_timeout_ms") => {
271 serde_json::to_value(config.config.locking.write_lock_timeout_ms)?
272 }
273 Some(&"stale_lock_timeout_ms") => {
274 serde_json::to_value(config.config.locking.stale_lock_timeout_ms)?
275 }
276 Some(&"stale_takeover_policy") => {
277 serde_json::to_value(&config.config.locking.stale_takeover_policy)?
278 }
279 _ => anyhow::bail!("Unknown locking key: {:?}", parts.get(1)),
280 },
281 "output" => match parts.get(1) {
282 Some(&"default_pagination") => {
283 serde_json::to_value(config.config.output.default_pagination)?
284 }
285 Some(&"page_size") => serde_json::to_value(config.config.output.page_size)?,
286 Some(&"max_preview_bytes") => {
287 serde_json::to_value(config.config.output.max_preview_bytes)?
288 }
289 _ => anyhow::bail!("Unknown output key: {:?}", parts.get(1)),
290 },
291 "parallelism" => match parts.get(1) {
292 Some(&"max_threads") => serde_json::to_value(config.config.parallelism.max_threads)?,
293 _ => anyhow::bail!("Unknown parallelism key: {:?}", parts.get(1)),
294 },
295 _ => anyhow::bail!("Unknown config section: {}", parts[0]),
296 };
297
298 if json {
299 let json_str = serde_json::to_string_pretty(&value)?;
300 println!("{json_str}");
301 } else {
302 println!("{value}");
303 }
304
305 Ok(())
306}
307
308pub fn run_config_set(path: Option<&str>, key: &str, value: &str, yes: bool) -> Result<()> {
313 let project_root = Path::new(path.unwrap_or("."));
314 let store = GraphConfigStore::new(project_root).context("Failed to create config store")?;
315
316 if !store.is_initialized() {
317 anyhow::bail!("Config not initialized. Run 'sqry config init' first.");
318 }
319
320 let persistence = ConfigPersistence::new(&store);
321 let (mut config, _report) = persistence.load().context("Failed to load config")?;
322
323 let old_value = get_config_value(&config, key)?;
325
326 set_config_value(&mut config, key, value)?;
328
329 config
331 .validate()
332 .context("Config validation failed after update")?;
333
334 if !yes {
336 println!("Config change:");
337 println!(" {key}: {old_value} → {value}");
338 println!();
339 print!("Apply this change? [y/N] ");
340
341 let stdin = io::stdin();
342 let mut line = String::new();
343 stdin.lock().read_line(&mut line)?;
344
345 if !line.trim().eq_ignore_ascii_case("y") {
346 println!("Cancelled.");
347 return Ok(());
348 }
349 }
350
351 persistence
353 .save(&mut config, 5000, "cli")
354 .context("Failed to save config")?;
355
356 println!("✓ Config updated: {key} = {value}");
357
358 Ok(())
359}
360
361fn get_config_value(config: &GraphConfigFile, key: &str) -> Result<String> {
363 let parts: Vec<&str> = key.split('.').collect();
364
365 let value = match parts[0] {
366 "limits" => match parts.get(1) {
367 Some(&"max_results") => config.config.limits.max_results.to_string(),
368 Some(&"max_depth") => config.config.limits.max_depth.to_string(),
369 Some(&"max_bytes_per_file") => config.config.limits.max_bytes_per_file.to_string(),
370 Some(&"max_files") => config.config.limits.max_files.to_string(),
371 Some(&"analysis_label_budget_per_kind") => config
372 .config
373 .limits
374 .analysis_label_budget_per_kind
375 .to_string(),
376 Some(&"analysis_density_gate_threshold") => config
377 .config
378 .limits
379 .analysis_density_gate_threshold
380 .to_string(),
381 Some(&"analysis_budget_exceeded_policy") => {
382 config.config.limits.analysis_budget_exceeded_policy.clone()
383 }
384 _ => anyhow::bail!("Unknown limits key: {:?}", parts.get(1)),
385 },
386 "locking" => match parts.get(1) {
387 Some(&"write_lock_timeout_ms") => {
388 config.config.locking.write_lock_timeout_ms.to_string()
389 }
390 Some(&"stale_lock_timeout_ms") => {
391 config.config.locking.stale_lock_timeout_ms.to_string()
392 }
393 Some(&"stale_takeover_policy") => config.config.locking.stale_takeover_policy.clone(),
394 _ => anyhow::bail!("Unknown locking key: {:?}", parts.get(1)),
395 },
396 "output" => match parts.get(1) {
397 Some(&"default_pagination") => config.config.output.default_pagination.to_string(),
398 Some(&"page_size") => config.config.output.page_size.to_string(),
399 Some(&"max_preview_bytes") => config.config.output.max_preview_bytes.to_string(),
400 _ => anyhow::bail!("Unknown output key: {:?}", parts.get(1)),
401 },
402 "parallelism" => match parts.get(1) {
403 Some(&"max_threads") => config.config.parallelism.max_threads.to_string(),
404 _ => anyhow::bail!("Unknown parallelism key: {:?}", parts.get(1)),
405 },
406 _ => anyhow::bail!("Unknown config section: {}", parts[0]),
407 };
408
409 Ok(value)
410}
411
412fn set_config_value(config: &mut GraphConfigFile, key: &str, value: &str) -> Result<()> {
414 let parts: Vec<&str> = key.split('.').collect();
415 let subsection = parts.get(1).copied();
416
417 match parts[0] {
418 "limits" => set_limits_config_value(config, subsection, value)?,
419 "locking" => set_locking_config_value(config, subsection, value)?,
420 "output" => set_output_config_value(config, subsection, value)?,
421 "parallelism" => set_parallelism_config_value(config, subsection, value)?,
422 _ => anyhow::bail!("Unknown config section: {}", parts[0]),
423 }
424
425 Ok(())
426}
427
428fn parse_u64_config_value(value: &str, context_message: &'static str) -> Result<u64> {
429 value.parse().context(context_message)
430}
431
432fn parse_bool_config_value(value: &str, context_message: &'static str) -> Result<bool> {
433 value.parse().context(context_message)
434}
435
436fn validate_enum_config_value(
437 key: &str,
438 value: &str,
439 allowed_values: &[&str],
440 expected_values: &str,
441) -> Result<()> {
442 if allowed_values.contains(&value) {
443 Ok(())
444 } else {
445 anyhow::bail!("Invalid {key} (expected: {expected_values})");
446 }
447}
448
449fn set_limits_config_value(
450 config: &mut GraphConfigFile,
451 subsection: Option<&str>,
452 value: &str,
453) -> Result<()> {
454 match subsection {
455 Some("max_results") => {
456 config.config.limits.max_results =
457 parse_u64_config_value(value, "Invalid value for max_results (expected u64)")?;
458 }
459 Some("max_depth") => {
460 config.config.limits.max_depth =
461 parse_u64_config_value(value, "Invalid value for max_depth (expected u64)")?;
462 }
463 Some("max_bytes_per_file") => {
464 config.config.limits.max_bytes_per_file = parse_u64_config_value(
465 value,
466 "Invalid value for max_bytes_per_file (expected u64)",
467 )?;
468 }
469 Some("max_files") => {
470 config.config.limits.max_files =
471 parse_u64_config_value(value, "Invalid value for max_files (expected u64)")?;
472 }
473 Some("analysis_label_budget_per_kind") => {
474 config.config.limits.analysis_label_budget_per_kind = parse_u64_config_value(
475 value,
476 "Invalid value for analysis_label_budget_per_kind (expected u64)",
477 )?;
478 }
479 Some("analysis_density_gate_threshold") => {
480 config.config.limits.analysis_density_gate_threshold = parse_u64_config_value(
481 value,
482 "Invalid value for analysis_density_gate_threshold (expected u64)",
483 )?;
484 }
485 Some("analysis_budget_exceeded_policy") => {
486 validate_enum_config_value(
487 "analysis_budget_exceeded_policy",
488 value,
489 &["degrade", "fail"],
490 "degrade or fail",
491 )?;
492 config.config.limits.analysis_budget_exceeded_policy = value.to_string();
493 }
494 _ => anyhow::bail!("Unknown limits key: {subsection:?}"),
495 }
496
497 Ok(())
498}
499
500fn set_locking_config_value(
501 config: &mut GraphConfigFile,
502 subsection: Option<&str>,
503 value: &str,
504) -> Result<()> {
505 match subsection {
506 Some("write_lock_timeout_ms") => {
507 config.config.locking.write_lock_timeout_ms = parse_u64_config_value(
508 value,
509 "Invalid value for write_lock_timeout_ms (expected u64)",
510 )?;
511 }
512 Some("stale_lock_timeout_ms") => {
513 config.config.locking.stale_lock_timeout_ms = parse_u64_config_value(
514 value,
515 "Invalid value for stale_lock_timeout_ms (expected u64)",
516 )?;
517 }
518 Some("stale_takeover_policy") => {
519 validate_enum_config_value(
520 "stale_takeover_policy",
521 value,
522 &["deny", "warn", "allow"],
523 "deny, warn, or allow",
524 )?;
525 config.config.locking.stale_takeover_policy = value.to_string();
526 }
527 _ => anyhow::bail!("Unknown locking key: {subsection:?}"),
528 }
529
530 Ok(())
531}
532
533fn set_output_config_value(
534 config: &mut GraphConfigFile,
535 subsection: Option<&str>,
536 value: &str,
537) -> Result<()> {
538 match subsection {
539 Some("default_pagination") => {
540 config.config.output.default_pagination = parse_bool_config_value(
541 value,
542 "Invalid value for default_pagination (expected bool)",
543 )?;
544 }
545 Some("page_size") => {
546 let page_size =
547 parse_u64_config_value(value, "Invalid value for page_size (expected u64)")?;
548 if page_size == 0 {
549 anyhow::bail!("page_size must be greater than 0");
550 }
551 config.config.output.page_size = page_size;
552 }
553 Some("max_preview_bytes") => {
554 config.config.output.max_preview_bytes = parse_u64_config_value(
555 value,
556 "Invalid value for max_preview_bytes (expected u64)",
557 )?;
558 }
559 _ => anyhow::bail!("Unknown output key: {subsection:?}"),
560 }
561
562 Ok(())
563}
564
565fn set_parallelism_config_value(
566 config: &mut GraphConfigFile,
567 subsection: Option<&str>,
568 value: &str,
569) -> Result<()> {
570 match subsection {
571 Some("max_threads") => {
572 config.config.parallelism.max_threads =
573 parse_u64_config_value(value, "Invalid value for max_threads (expected u64)")?;
574 }
575 _ => anyhow::bail!("Unknown parallelism key: {subsection:?}"),
576 }
577
578 Ok(())
579}
580
581pub fn run_config_get(path: Option<&str>, key: &str) -> Result<()> {
586 let project_root = Path::new(path.unwrap_or("."));
587 let store = GraphConfigStore::new(project_root).context("Failed to create config store")?;
588
589 if !store.is_initialized() {
590 anyhow::bail!("Config not initialized. Run 'sqry config init' first.");
591 }
592
593 let persistence = ConfigPersistence::new(&store);
594 let (config, _report) = persistence.load().context("Failed to load config")?;
595
596 let value = get_config_value(&config, key)?;
597 println!("{value}");
598
599 Ok(())
600}
601
602pub fn run_config_validate(path: Option<&str>) -> Result<()> {
607 let project_root = Path::new(path.unwrap_or("."));
608 let store = GraphConfigStore::new(project_root).context("Failed to create config store")?;
609
610 if !store.is_initialized() {
611 anyhow::bail!("Config not initialized. Run 'sqry config init' first.");
612 }
613
614 let persistence = ConfigPersistence::new(&store);
615
616 match persistence.load() {
617 Ok((config, report)) => {
618 if !report.warnings.is_empty() {
620 println!("⚠ Warnings:");
621 for warning in &report.warnings {
622 println!(" - {warning}");
623 }
624 println!();
625 }
626
627 match config.validate() {
629 Ok(()) => {
630 println!("✓ Config is valid");
631 println!(" Schema version: {}", config.schema_version);
632 println!(" Integrity: {:?}", report.integrity_status);
633 Ok(())
634 }
635 Err(e) => {
636 eprintln!("✗ Config validation failed: {e}");
637 Err(anyhow!("Validation failed"))
638 }
639 }
640 }
641 Err(e) => {
642 eprintln!("✗ Failed to load config: {e}");
643 Err(anyhow!("Load failed"))
644 }
645 }
646}
647
648pub fn run_config_alias_set(
653 path: Option<&str>,
654 name: &str,
655 query: &str,
656 description: Option<&str>,
657) -> Result<()> {
658 let project_root = Path::new(path.unwrap_or("."));
659 let store = GraphConfigStore::new(project_root).context("Failed to create config store")?;
660
661 if !store.is_initialized() {
662 anyhow::bail!("Config not initialized. Run 'sqry config init' first.");
663 }
664
665 let persistence = ConfigPersistence::new(&store);
666 let (mut config, _report) = persistence.load().context("Failed to load config")?;
667
668 let is_update = config.config.aliases.contains_key(name);
670
671 let alias_entry = AliasEntry::new(query, description.map(String::from));
673 config.config.aliases.insert(name.to_string(), alias_entry);
674
675 persistence
677 .save(&mut config, 5000, "cli")
678 .context("Failed to save config")?;
679
680 if is_update {
681 println!("✓ Alias '{name}' updated");
682 } else {
683 println!("✓ Alias '{name}' created");
684 }
685 println!(" Query: {query}");
686 if let Some(desc) = description {
687 println!(" Description: {desc}");
688 }
689
690 Ok(())
691}
692
693pub fn run_config_alias_list(path: Option<&str>, json: bool) -> Result<()> {
698 let project_root = Path::new(path.unwrap_or("."));
699 let store = GraphConfigStore::new(project_root).context("Failed to create config store")?;
700
701 if !store.is_initialized() {
702 anyhow::bail!("Config not initialized. Run 'sqry config init' first.");
703 }
704
705 let persistence = ConfigPersistence::new(&store);
706 let (config, _report) = persistence.load().context("Failed to load config")?;
707
708 if config.config.aliases.is_empty() {
709 println!("No aliases defined.");
710 return Ok(());
711 }
712
713 if json {
714 let json_str = serde_json::to_string_pretty(&config.config.aliases)
715 .context("Failed to serialize aliases")?;
716 println!("{json_str}");
717 } else {
718 println!("Aliases ({}):", config.config.aliases.len());
719 for (name, alias) in &config.config.aliases {
720 println!();
721 println!(" {name}");
722 println!(" Query: {}", alias.query);
723 if let Some(desc) = &alias.description {
724 println!(" Description: {desc}");
725 }
726 println!(" Created: {}", alias.created_at);
727 println!(" Updated: {}", alias.updated_at);
728 }
729 }
730
731 Ok(())
732}
733
734pub fn run_config_alias_remove(path: Option<&str>, name: &str) -> Result<()> {
739 let project_root = Path::new(path.unwrap_or("."));
740 let store = GraphConfigStore::new(project_root).context("Failed to create config store")?;
741
742 if !store.is_initialized() {
743 anyhow::bail!("Config not initialized. Run 'sqry config init' first.");
744 }
745
746 let persistence = ConfigPersistence::new(&store);
747 let (mut config, _report) = persistence.load().context("Failed to load config")?;
748
749 if !config.config.aliases.contains_key(name) {
751 anyhow::bail!("Alias '{name}' not found");
752 }
753
754 config.config.aliases.remove(name);
756
757 persistence
759 .save(&mut config, 5000, "cli")
760 .context("Failed to save config")?;
761
762 println!("✓ Alias '{name}' removed");
763
764 Ok(())
765}
766
767fn format_bytes(bytes: u64) -> String {
773 if bytes == 0 {
774 return "unlimited".to_string();
775 }
776
777 if bytes >= GB_BYTES {
778 format!("{:.2} GB", u64_to_f64_lossy(bytes) / GB_BYTES_F64)
779 } else if bytes >= MB_BYTES {
780 format!("{:.2} MB", u64_to_f64_lossy(bytes) / MB_BYTES_F64)
781 } else if bytes >= KB_BYTES {
782 format!("{:.2} KB", u64_to_f64_lossy(bytes) / KB_BYTES_F64)
783 } else {
784 format!("{bytes} bytes")
785 }
786}
787
788fn u64_to_f64_lossy(value: u64) -> f64 {
789 let narrowed = u32::try_from(value).unwrap_or(u32::MAX);
790 f64::from(narrowed)
791}
792
793#[cfg(test)]
794mod tests {
795 use super::set_config_value;
796 use sqry_core::config::graph_config_schema::GraphConfigFile;
797
798 #[test]
799 fn set_config_value_updates_each_supported_section() {
800 let mut config = GraphConfigFile::default();
801
802 set_config_value(&mut config, "limits.max_results", "9000").unwrap();
803 set_config_value(&mut config, "locking.stale_takeover_policy", "warn").unwrap();
804 set_config_value(&mut config, "output.page_size", "25").unwrap();
805 set_config_value(&mut config, "parallelism.max_threads", "6").unwrap();
806
807 assert_eq!(config.config.limits.max_results, 9000);
808 assert_eq!(config.config.locking.stale_takeover_policy, "warn");
809 assert_eq!(config.config.output.page_size, 25);
810 assert_eq!(config.config.parallelism.max_threads, 6);
811 }
812
813 #[test]
814 fn set_config_value_rejects_invalid_limits_enum() {
815 let mut config = GraphConfigFile::default();
816
817 let error = set_config_value(
818 &mut config,
819 "limits.analysis_budget_exceeded_policy",
820 "panic",
821 )
822 .unwrap_err();
823
824 assert!(
825 error
826 .to_string()
827 .contains("Invalid analysis_budget_exceeded_policy")
828 );
829 }
830
831 #[test]
832 fn set_config_value_rejects_zero_page_size() {
833 let mut config = GraphConfigFile::default();
834
835 let error = set_config_value(&mut config, "output.page_size", "0").unwrap_err();
836
837 assert!(
838 error
839 .to_string()
840 .contains("page_size must be greater than 0")
841 );
842 }
843
844 #[test]
845 fn set_config_value_rejects_unknown_section() {
846 let mut config = GraphConfigFile::default();
847
848 let error = set_config_value(&mut config, "unknown.key", "1").unwrap_err();
849
850 assert!(error.to_string().contains("Unknown config section"));
851 }
852
853 #[test]
854 fn set_limits_all_u64_keys() {
855 let mut config = GraphConfigFile::default();
856
857 set_config_value(&mut config, "limits.max_depth", "42").unwrap();
858 set_config_value(&mut config, "limits.max_bytes_per_file", "8192").unwrap();
859 set_config_value(&mut config, "limits.max_files", "500").unwrap();
860 set_config_value(
861 &mut config,
862 "limits.analysis_label_budget_per_kind",
863 "100000",
864 )
865 .unwrap();
866 set_config_value(&mut config, "limits.analysis_density_gate_threshold", "75").unwrap();
867
868 assert_eq!(config.config.limits.max_depth, 42);
869 assert_eq!(config.config.limits.max_bytes_per_file, 8192);
870 assert_eq!(config.config.limits.max_files, 500);
871 assert_eq!(config.config.limits.analysis_label_budget_per_kind, 100_000);
872 assert_eq!(config.config.limits.analysis_density_gate_threshold, 75);
873 }
874
875 #[test]
876 fn set_limits_budget_policy_valid_values() {
877 let mut config = GraphConfigFile::default();
878
879 set_config_value(
880 &mut config,
881 "limits.analysis_budget_exceeded_policy",
882 "fail",
883 )
884 .unwrap();
885 assert_eq!(config.config.limits.analysis_budget_exceeded_policy, "fail");
886
887 set_config_value(
888 &mut config,
889 "limits.analysis_budget_exceeded_policy",
890 "degrade",
891 )
892 .unwrap();
893 assert_eq!(
894 config.config.limits.analysis_budget_exceeded_policy,
895 "degrade"
896 );
897 }
898
899 #[test]
900 fn set_limits_rejects_unknown_key() {
901 let mut config = GraphConfigFile::default();
902 let err = set_config_value(&mut config, "limits.nonexistent", "1").unwrap_err();
903 assert!(err.to_string().contains("Unknown limits key"));
904 }
905
906 #[test]
907 fn set_limits_rejects_non_numeric() {
908 let mut config = GraphConfigFile::default();
909 let err = set_config_value(&mut config, "limits.max_results", "abc").unwrap_err();
910 assert!(err.to_string().contains("Invalid"));
911 }
912
913 #[test]
914 fn set_locking_all_keys() {
915 let mut config = GraphConfigFile::default();
916
917 set_config_value(&mut config, "locking.write_lock_timeout_ms", "5000").unwrap();
918 set_config_value(&mut config, "locking.stale_lock_timeout_ms", "30000").unwrap();
919
920 assert_eq!(config.config.locking.write_lock_timeout_ms, 5000);
921 assert_eq!(config.config.locking.stale_lock_timeout_ms, 30000);
922 }
923
924 #[test]
925 fn set_locking_stale_takeover_policy_valid_values() {
926 let mut config = GraphConfigFile::default();
927
928 for policy in &["deny", "warn", "allow"] {
929 set_config_value(&mut config, "locking.stale_takeover_policy", policy).unwrap();
930 assert_eq!(config.config.locking.stale_takeover_policy, *policy);
931 }
932 }
933
934 #[test]
935 fn set_locking_rejects_invalid_policy() {
936 let mut config = GraphConfigFile::default();
937 let err =
938 set_config_value(&mut config, "locking.stale_takeover_policy", "yolo").unwrap_err();
939 assert!(err.to_string().contains("Invalid stale_takeover_policy"));
940 }
941
942 #[test]
943 fn set_locking_rejects_unknown_key() {
944 let mut config = GraphConfigFile::default();
945 let err = set_config_value(&mut config, "locking.nonexistent", "1").unwrap_err();
946 assert!(err.to_string().contains("Unknown locking key"));
947 }
948
949 #[test]
950 fn set_output_all_keys() {
951 let mut config = GraphConfigFile::default();
952
953 set_config_value(&mut config, "output.default_pagination", "true").unwrap();
954 set_config_value(&mut config, "output.max_preview_bytes", "4096").unwrap();
955
956 assert!(config.config.output.default_pagination);
957 assert_eq!(config.config.output.max_preview_bytes, 4096);
958 }
959
960 #[test]
961 fn set_output_rejects_invalid_bool() {
962 let mut config = GraphConfigFile::default();
963 let err = set_config_value(&mut config, "output.default_pagination", "maybe").unwrap_err();
964 assert!(err.to_string().contains("Invalid"));
965 }
966
967 #[test]
968 fn set_output_rejects_unknown_key() {
969 let mut config = GraphConfigFile::default();
970 let err = set_config_value(&mut config, "output.nonexistent", "1").unwrap_err();
971 assert!(err.to_string().contains("Unknown output key"));
972 }
973
974 #[test]
975 fn set_parallelism_rejects_unknown_key() {
976 let mut config = GraphConfigFile::default();
977 let err = set_config_value(&mut config, "parallelism.nonexistent", "1").unwrap_err();
978 assert!(err.to_string().contains("Unknown parallelism key"));
979 }
980}