Skip to main content

sqry_cli/commands/
config.rs

1//! Config command implementation for unified graph config partition
2//!
3//! Provides CLI access to `.sqry/graph/config/config.json` management:
4//! - init: Create config with defaults
5//! - show: Display effective config
6//! - set: Update config keys
7//! - get: Retrieve config values
8//! - validate: Check config syntax/schema
9//! - alias: Manage query aliases
10
11use 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
27// ============================================================================
28// Config subcommands
29// ============================================================================
30
31/// Initialize a new config file with defaults.
32///
33/// # Errors
34/// Returns an error if the config store cannot be created, validation fails,
35/// or the config cannot be initialized.
36pub 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    // Check if already initialized
41    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    // Validate filesystem (check for network filesystems)
49    store
50        .validate(false)
51        .context("Filesystem validation failed")?;
52
53    // Initialize config with defaults
54    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
69/// Show effective config with source annotations.
70///
71/// # Errors
72/// Returns an error if the config store cannot be opened, config loading fails,
73/// or the requested key is invalid.
74pub 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 specific key requested, show only that value
88    if let Some(key_path) = key {
89        return show_config_key(&config, key_path, json);
90    }
91
92    // Show full config
93    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)] // Linear human formatter: each config section is emitted once, and splitting would only create trivial println wrappers.
115fn 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
240/// Show a specific config key
241fn show_config_key(config: &GraphConfigFile, key_path: &str, json: bool) -> Result<()> {
242    // Parse key path (e.g., "limits.max_results")
243    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    // Navigate the config structure
250    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
308/// Set a config key to a new value.
309///
310/// # Errors
311/// Returns an error if the config cannot be loaded, validation fails, or the key is invalid.
312pub 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    // Store old value for diff
324    let old_value = get_config_value(&config, key)?;
325
326    // Set new value
327    set_config_value(&mut config, key, value)?;
328
329    // Validate the updated config
330    config
331        .validate()
332        .context("Config validation failed after update")?;
333
334    // Show diff and confirm
335    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    // Save updated config
352    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
361/// Get current value of a config key
362fn 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
412/// Set a config value
413fn 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
581/// Get a single config value.
582///
583/// # Errors
584/// Returns an error if the config cannot be loaded or the key is invalid.
585pub 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
602/// Validate config file.
603///
604/// # Errors
605/// Returns an error if the config cannot be loaded or fails validation.
606pub 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            // Check for warnings
619            if !report.warnings.is_empty() {
620                println!("⚠ Warnings:");
621                for warning in &report.warnings {
622                    println!("  - {warning}");
623                }
624                println!();
625            }
626
627            // Validate schema
628            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
648/// Create or update an alias.
649///
650/// # Errors
651/// Returns an error if the config cannot be loaded or saved.
652pub 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    // Check if alias already exists
669    let is_update = config.config.aliases.contains_key(name);
670
671    // Create/update alias
672    let alias_entry = AliasEntry::new(query, description.map(String::from));
673    config.config.aliases.insert(name.to_string(), alias_entry);
674
675    // Save updated config
676    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
693/// List all aliases.
694///
695/// # Errors
696/// Returns an error if the config cannot be loaded or aliases cannot be serialized.
697pub 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
734/// Remove an alias.
735///
736/// # Errors
737/// Returns an error if the config cannot be loaded or the alias does not exist.
738pub 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    // Check if alias exists
750    if !config.config.aliases.contains_key(name) {
751        anyhow::bail!("Alias '{name}' not found");
752    }
753
754    // Remove alias
755    config.config.aliases.remove(name);
756
757    // Save updated config
758    persistence
759        .save(&mut config, 5000, "cli")
760        .context("Failed to save config")?;
761
762    println!("✓ Alias '{name}' removed");
763
764    Ok(())
765}
766
767// ============================================================================
768// Helper functions
769// ============================================================================
770
771/// Format bytes as human-readable string
772fn 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}