Skip to main content

aperture_cli/cli/commands/
config.rs

1//! Handlers for `aperture config *` subcommands.
2
3use crate::config::context_name::ApiContextName;
4use crate::config::manager::{get_config_dir, ConfigManager};
5use crate::config::models::SecretSource;
6use crate::constants;
7use crate::error::Error;
8use crate::fs::OsFileSystem;
9use crate::output::Output;
10use crate::response_cache::{CacheConfig, ResponseCache};
11use std::path::PathBuf;
12
13/// Validates and returns the API context name, returning an error for invalid names.
14pub fn validate_api_name(name: &str) -> Result<ApiContextName, Error> {
15    ApiContextName::new(name)
16}
17
18/// Execute `aperture config <subcommand>`.
19#[allow(clippy::too_many_lines)]
20pub async fn execute_config_command(
21    manager: &ConfigManager<OsFileSystem>,
22    command: crate::cli::ConfigCommands,
23    output: &Output,
24) -> Result<(), Error> {
25    match command {
26        crate::cli::ConfigCommands::Add {
27            name,
28            file_or_url,
29            force,
30            strict,
31        } => {
32            let name = validate_api_name(&name)?;
33            manager
34                .add_spec_auto(&name, &file_or_url, force, strict)
35                .await?;
36            output.success(format!("Spec '{name}' added successfully."));
37        }
38        crate::cli::ConfigCommands::List { verbose } => {
39            let specs = manager.list_specs()?;
40            if specs.is_empty() {
41                output.info("No API specifications found.");
42            } else {
43                output.info("Registered API specifications:");
44                list_specs_with_details(manager, specs, verbose, output);
45            }
46        }
47        crate::cli::ConfigCommands::Remove { name } => {
48            let name = validate_api_name(&name)?;
49            manager.remove_spec(&name)?;
50            output.success(format!("Spec '{name}' removed successfully."));
51        }
52        crate::cli::ConfigCommands::Edit { name } => {
53            let name = validate_api_name(&name)?;
54            manager.edit_spec(&name)?;
55            output.success(format!("Opened spec '{name}' in editor."));
56        }
57        crate::cli::ConfigCommands::SetUrl { name, url, env } => {
58            let name = validate_api_name(&name)?;
59            manager.set_url(&name, &url, env.as_deref())?;
60            if let Some(environment) = env {
61                output.success(format!(
62                    "Set base URL for '{name}' in environment '{environment}': {url}"
63                ));
64            } else {
65                output.success(format!("Set base URL for '{name}': {url}"));
66            }
67        }
68        crate::cli::ConfigCommands::GetUrl { name } => {
69            let name = validate_api_name(&name)?;
70            let (base_override, env_urls, resolved) = manager.get_url(&name)?;
71            print_url_configuration(
72                &name,
73                base_override.as_deref(),
74                &env_urls,
75                &resolved,
76                output,
77            );
78        }
79        crate::cli::ConfigCommands::ListUrls {} => {
80            let all_urls = manager.list_urls()?;
81            if all_urls.is_empty() {
82                output.info("No base URLs configured.");
83                return Ok(());
84            }
85            output.info("Configured base URLs:");
86            for (api_name, (base_override, env_urls)) in all_urls {
87                print_api_url_entry(&api_name, base_override.as_deref(), &env_urls, output);
88            }
89        }
90        crate::cli::ConfigCommands::Reinit { context, all } => {
91            if all {
92                reinit_all_specs(manager, output)?;
93                return Ok(());
94            }
95            let Some(spec_name) = context else {
96                eprintln!("Error: Either specify a spec name or use --all flag");
97                std::process::exit(1);
98            };
99            let spec_name = validate_api_name(&spec_name)?;
100            reinit_spec(manager, &spec_name, output)?;
101        }
102        crate::cli::ConfigCommands::ClearCache { api_name, all } => {
103            if let Some(ref name) = api_name {
104                validate_api_name(name)?;
105            }
106            clear_response_cache(manager, api_name.as_deref(), all, output).await?;
107        }
108        crate::cli::ConfigCommands::CacheStats { api_name } => {
109            if let Some(ref name) = api_name {
110                validate_api_name(name)?;
111            }
112            show_cache_stats(manager, api_name.as_deref(), output).await?;
113        }
114        crate::cli::ConfigCommands::SetSecret {
115            api_name,
116            scheme_name,
117            env,
118            interactive,
119        } => {
120            let api_name = validate_api_name(&api_name)?;
121            if interactive {
122                manager.set_secret_interactive(&api_name)?;
123                return Ok(());
124            }
125            let (Some(scheme), Some(env_var)) = (scheme_name, env) else {
126                return Err(Error::invalid_config(
127                    "Either provide --scheme and --env, or use --interactive",
128                ));
129            };
130            manager.set_secret(&api_name, &scheme, &env_var)?;
131            output.success(format!(
132                "Set secret for scheme '{scheme}' in API '{api_name}' to use environment variable '{env_var}'"
133            ));
134        }
135        crate::cli::ConfigCommands::ListSecrets { api_name } => {
136            let api_name = validate_api_name(&api_name)?;
137            let secrets = manager.list_secrets(&api_name)?;
138            if secrets.is_empty() {
139                output.info(format!("No secrets configured for API '{api_name}'"));
140            } else {
141                print_secrets_list(&api_name, secrets, output);
142            }
143        }
144        crate::cli::ConfigCommands::RemoveSecret {
145            api_name,
146            scheme_name,
147        } => {
148            let api_name = validate_api_name(&api_name)?;
149            manager.remove_secret(&api_name, &scheme_name)?;
150            output.success(format!(
151                "Removed secret configuration for scheme '{scheme_name}' from API '{api_name}'"
152            ));
153        }
154        crate::cli::ConfigCommands::ClearSecrets { api_name, force } => {
155            let api_name = validate_api_name(&api_name)?;
156            let secrets = manager.list_secrets(&api_name)?;
157            if secrets.is_empty() {
158                output.info(format!("No secrets configured for API '{api_name}'"));
159                return Ok(());
160            }
161            if force {
162                manager.clear_secrets(&api_name)?;
163                output.success(format!(
164                    "Cleared all secret configurations for API '{api_name}'"
165                ));
166                return Ok(());
167            }
168            output.info(format!(
169                "This will remove all {} secret configuration(s) for API '{api_name}':",
170                secrets.len()
171            ));
172            for scheme_name in secrets.keys() {
173                output.info(format!("  - {scheme_name}"));
174            }
175            if !crate::interactive::confirm("Are you sure you want to continue?")? {
176                output.info("Operation cancelled");
177                return Ok(());
178            }
179            manager.clear_secrets(&api_name)?;
180            output.success(format!(
181                "Cleared all secret configurations for API '{api_name}'"
182            ));
183        }
184        crate::cli::ConfigCommands::Set { key, value } => {
185            use crate::config::settings::{SettingKey, SettingValue};
186            let setting_key: SettingKey = key.parse()?;
187            let setting_value = SettingValue::parse_for_key(setting_key, &value)?;
188            manager.set_setting(&setting_key, &setting_value)?;
189            output.success(format!("Set {key} = {value}"));
190        }
191        crate::cli::ConfigCommands::Get { key, json } => {
192            use crate::config::settings::SettingKey;
193            let setting_key: SettingKey = key.parse()?;
194            let value = manager.get_setting(&setting_key)?;
195            if json {
196                // ast-grep-ignore: no-println
197                println!(
198                    "{}",
199                    serde_json::json!({ "key": key, "value": value.to_string() })
200                );
201            } else {
202                // ast-grep-ignore: no-println
203                println!("{value}");
204            }
205        }
206        crate::cli::ConfigCommands::Settings { json } => {
207            let settings = manager.list_settings()?;
208            print_settings_list(settings, json, output)?;
209        }
210        crate::cli::ConfigCommands::SetMapping {
211            api_name,
212            group,
213            operation,
214            name,
215            op_group,
216            alias,
217            remove_alias,
218            hidden,
219            visible,
220        } => {
221            let api_name = validate_api_name(&api_name)?;
222            handle_set_mapping(
223                manager,
224                &api_name,
225                group.as_deref(),
226                operation.as_deref(),
227                name.as_deref(),
228                op_group.as_deref(),
229                alias.as_deref(),
230                remove_alias.as_deref(),
231                hidden,
232                visible,
233                output,
234            )?;
235        }
236        crate::cli::ConfigCommands::ListMappings { api_name } => {
237            let api_name = validate_api_name(&api_name)?;
238            handle_list_mappings(manager, &api_name, output)?;
239        }
240        crate::cli::ConfigCommands::RemoveMapping {
241            api_name,
242            group,
243            operation,
244        } => {
245            let api_name = validate_api_name(&api_name)?;
246            handle_remove_mapping(manager, &api_name, group, operation, output)?;
247        }
248    }
249
250    Ok(())
251}
252
253/// Print the list of configured secrets for an API
254pub fn print_secrets_list(
255    api_name: &str,
256    secrets: std::collections::HashMap<String, crate::config::models::ApertureSecret>,
257    output: &Output,
258) {
259    output.info(format!("Configured secrets for API '{api_name}':"));
260    for (scheme_name, secret) in secrets {
261        match secret.source {
262            SecretSource::Env => {
263                // ast-grep-ignore: no-println
264                println!("  {scheme_name}: environment variable '{}'", secret.name);
265            }
266        }
267    }
268}
269
270/// Print a single API URL entry in the list
271pub fn print_api_url_entry(
272    api_name: &str,
273    base_override: Option<&str>,
274    env_urls: &std::collections::HashMap<String, String>,
275    output: &Output,
276) {
277    // ast-grep-ignore: no-println
278    println!("\n{api_name}:");
279    if let Some(base) = base_override {
280        // ast-grep-ignore: no-println
281        println!("  Base override: {base}");
282    }
283    if !env_urls.is_empty() {
284        output.info("  Environment URLs:");
285        for (env, url) in env_urls {
286            // ast-grep-ignore: no-println
287            println!("    {env}: {url}");
288        }
289    }
290}
291
292/// Print URL configuration for a specific API
293pub fn print_url_configuration(
294    name: &str,
295    base_override: Option<&str>,
296    env_urls: &std::collections::HashMap<String, String>,
297    resolved: &str,
298    output: &Output,
299) {
300    output.info(format!("Base URL configuration for '{name}':"));
301    if let Some(base) = base_override {
302        // ast-grep-ignore: no-println
303        println!("  Base override: {base}");
304    } else {
305        // ast-grep-ignore: no-println
306        println!("  Base override: (none)");
307    }
308    if !env_urls.is_empty() {
309        // ast-grep-ignore: no-println
310        println!("  Environment URLs:");
311        for (env, url) in env_urls {
312            // ast-grep-ignore: no-println
313            println!("    {env}: {url}");
314        }
315    }
316    // ast-grep-ignore: no-println
317    println!("\nResolved URL (current): {resolved}");
318    if let Ok(current_env) = std::env::var(constants::ENV_APERTURE_ENV) {
319        output.info(format!("(Using APERTURE_ENV={current_env})"));
320    }
321}
322
323pub fn reinit_spec(
324    manager: &ConfigManager<OsFileSystem>,
325    spec_name: &ApiContextName,
326    output: &Output,
327) -> Result<(), Error> {
328    output.info(format!("Reinitializing cached specification: {spec_name}"));
329    let specs = manager.list_specs()?;
330    if !specs.contains(&spec_name.to_string()) {
331        return Err(Error::spec_not_found(spec_name.as_str()));
332    }
333    let config_dir = if let Ok(dir) = std::env::var(constants::ENV_APERTURE_CONFIG_DIR) {
334        PathBuf::from(dir)
335    } else {
336        get_config_dir()?
337    };
338    let specs_dir = config_dir.join(constants::DIR_SPECS);
339    let spec_path = specs_dir.join(format!("{spec_name}.yaml"));
340    let strict = manager.get_strict_preference(spec_name).unwrap_or(false);
341    manager.add_spec(spec_name, &spec_path, true, strict)?;
342    output.success(format!(
343        "Successfully reinitialized cache for '{spec_name}'"
344    ));
345    Ok(())
346}
347
348pub fn reinit_all_specs(
349    manager: &ConfigManager<OsFileSystem>,
350    output: &Output,
351) -> Result<(), Error> {
352    let specs = manager.list_specs()?;
353    if specs.is_empty() {
354        output.info("No API specifications found to reinitialize.");
355        return Ok(());
356    }
357    output.info(format!(
358        "Reinitializing {} cached specification(s)...",
359        specs.len()
360    ));
361    for spec_name in &specs {
362        let validated = match validate_api_name(spec_name) {
363            Ok(v) => v,
364            Err(e) => {
365                eprintln!("  {spec_name}: {e}");
366                continue;
367            }
368        };
369        match reinit_spec(manager, &validated, output) {
370            Ok(()) => output.info(format!("  {spec_name}")),
371            Err(e) => eprintln!("  {spec_name}: {e}"),
372        }
373    }
374    output.success("Reinitialization complete.");
375    Ok(())
376}
377
378pub fn list_specs_with_details(
379    manager: &ConfigManager<OsFileSystem>,
380    specs: Vec<String>,
381    verbose: bool,
382    output: &Output,
383) {
384    let cache_dir = manager.config_dir().join(constants::DIR_CACHE);
385    for spec_name in specs {
386        if !verbose {
387            // ast-grep-ignore: no-println
388            println!("- {spec_name}");
389            continue;
390        }
391        let Ok(cached_spec) = crate::engine::loader::load_cached_spec(&cache_dir, &spec_name)
392        else {
393            // ast-grep-ignore: no-println
394            println!("- {spec_name}");
395            continue;
396        };
397        // ast-grep-ignore: no-println
398        println!("- {spec_name}:");
399        output.info(format!("  Version: {}", cached_spec.version));
400        let available = cached_spec.commands.len();
401        let skipped = cached_spec.skipped_endpoints.len();
402        let total = available + skipped;
403        if skipped > 0 {
404            output.info(format!(
405                "  Endpoints: {available} of {total} available ({skipped} skipped)"
406            ));
407            display_skipped_endpoints_info(&cached_spec, output);
408        } else {
409            output.info(format!("  Endpoints: {available} available"));
410        }
411    }
412}
413
414fn display_skipped_endpoints_info(cached_spec: &crate::cache::models::CachedSpec, output: &Output) {
415    output.info("  Skipped endpoints:");
416    for endpoint in &cached_spec.skipped_endpoints {
417        output.info(format!(
418            "    - {} {} - {} not supported",
419            endpoint.method, endpoint.path, endpoint.content_type
420        ));
421    }
422}
423
424pub fn print_settings_list(
425    settings: Vec<crate::config::settings::SettingInfo>,
426    json: bool,
427    output: &Output,
428) -> Result<(), Error> {
429    if json {
430        // ast-grep-ignore: no-println
431        println!("{}", serde_json::to_string_pretty(&settings)?);
432        return Ok(());
433    }
434    output.info("Available configuration settings:");
435    // ast-grep-ignore: no-println
436    println!();
437    for setting in settings {
438        // ast-grep-ignore: no-println
439        println!("  {} = {}", setting.key, setting.value);
440        // ast-grep-ignore: no-println
441        println!(
442            "    Type: {}  Default: {}",
443            setting.type_name, setting.default
444        );
445        // ast-grep-ignore: no-println
446        println!("    {}", setting.description);
447        // ast-grep-ignore: no-println
448        println!();
449    }
450    Ok(())
451}
452
453/// Clear response cache for a specific API or all APIs
454pub async fn clear_response_cache(
455    _manager: &ConfigManager<OsFileSystem>,
456    api_name: Option<&str>,
457    all: bool,
458    output: &Output,
459) -> Result<(), Error> {
460    let config_dir = if let Ok(dir) = std::env::var(constants::ENV_APERTURE_CONFIG_DIR) {
461        PathBuf::from(dir)
462    } else {
463        get_config_dir()?
464    };
465    let cache_config = CacheConfig {
466        cache_dir: config_dir
467            .join(constants::DIR_CACHE)
468            .join(constants::DIR_RESPONSES),
469        ..Default::default()
470    };
471    let cache = ResponseCache::new(cache_config)?;
472    let cleared_count = if all {
473        cache.clear_all().await?
474    } else {
475        let Some(api) = api_name else {
476            eprintln!("Error: Either specify an API name or use --all flag");
477            std::process::exit(1);
478        };
479        cache.clear_api_cache(api).await?
480    };
481    if all {
482        output.success(format!(
483            "Cleared {cleared_count} cached responses for all APIs"
484        ));
485    } else {
486        let Some(api) = api_name else {
487            unreachable!("API name must be Some if not all");
488        };
489        output.success(format!(
490            "Cleared {cleared_count} cached responses for API '{api}'"
491        ));
492    }
493    Ok(())
494}
495
496/// Show cache statistics for a specific API or all APIs
497pub async fn show_cache_stats(
498    _manager: &ConfigManager<OsFileSystem>,
499    api_name: Option<&str>,
500    output: &Output,
501) -> Result<(), Error> {
502    let config_dir = if let Ok(dir) = std::env::var(constants::ENV_APERTURE_CONFIG_DIR) {
503        PathBuf::from(dir)
504    } else {
505        get_config_dir()?
506    };
507    let cache_config = CacheConfig {
508        cache_dir: config_dir
509            .join(constants::DIR_CACHE)
510            .join(constants::DIR_RESPONSES),
511        ..Default::default()
512    };
513    let cache = ResponseCache::new(cache_config)?;
514    let stats = cache.get_stats(api_name).await?;
515    if let Some(api) = api_name {
516        output.info(format!("Cache statistics for API '{api}':"));
517    } else {
518        output.info("Cache statistics for all APIs:");
519    }
520    // ast-grep-ignore: no-println
521    println!("  Total entries: {}", stats.total_entries);
522    // ast-grep-ignore: no-println
523    println!("  Valid entries: {}", stats.valid_entries);
524    // ast-grep-ignore: no-println
525    println!("  Expired entries: {}", stats.expired_entries);
526    #[allow(clippy::cast_precision_loss)]
527    let size_mb = stats.total_size_bytes as f64 / 1024.0 / 1024.0;
528    // ast-grep-ignore: no-println
529    println!("  Total size: {size_mb:.2} MB");
530    if stats.total_entries != 0 {
531        #[allow(clippy::cast_precision_loss)]
532        let hit_rate = stats.valid_entries as f64 / stats.total_entries as f64 * 100.0;
533        // ast-grep-ignore: no-println
534        println!("  Hit rate: {hit_rate:.1}%");
535    }
536    Ok(())
537}
538
539// ── Command Mapping Handlers ──
540
541/// Handle the `config set-mapping` command
542#[allow(clippy::too_many_arguments, clippy::fn_params_excessive_bools)]
543pub fn handle_set_mapping(
544    manager: &ConfigManager<OsFileSystem>,
545    api_name: &crate::config::context_name::ApiContextName,
546    group: Option<&[String]>,
547    operation: Option<&str>,
548    name: Option<&str>,
549    op_group: Option<&str>,
550    alias: Option<&str>,
551    remove_alias: Option<&str>,
552    hidden: bool,
553    visible: bool,
554    output: &Output,
555) -> Result<(), Error> {
556    // Handle group rename
557    if let Some([original, new_name, ..]) = group {
558        manager.set_group_mapping(api_name, original, new_name)?;
559        output.success(format!(
560            "Set group mapping for '{api_name}': '{original}' → '{new_name}'"
561        ));
562        output.info("Run 'aperture config reinit' to apply changes.");
563        return Ok(());
564    }
565
566    // Handle operation mapping
567    let Some(op_id) = operation else {
568        return Err(Error::invalid_config(
569            "Either --group or --operation must be specified",
570        ));
571    };
572
573    let hidden_flag = match (hidden, visible) {
574        (true, _) => Some(true),
575        (_, true) => Some(false),
576        _ => None,
577    };
578
579    manager.set_operation_mapping(api_name, op_id, name, op_group, alias, hidden_flag)?;
580
581    // Handle alias removal (after set, so add + remove in one call is remove-wins)
582    if let Some(alias_to_remove) = remove_alias {
583        manager.remove_alias(api_name, op_id, alias_to_remove)?;
584    }
585
586    // Build a descriptive message
587    let mut changes = Vec::new();
588    if let Some(n) = name {
589        changes.push(format!("name='{n}'"));
590    }
591    if let Some(g) = op_group {
592        changes.push(format!("group='{g}'"));
593    }
594    if let Some(a) = alias {
595        changes.push(format!("alias+='{a}'"));
596    }
597    if let Some(a) = remove_alias {
598        changes.push(format!("alias-='{a}'"));
599    }
600    if hidden {
601        changes.push("hidden=true".to_string());
602    }
603    if visible {
604        changes.push("hidden=false".to_string());
605    }
606
607    let change_desc = if changes.is_empty() {
608        "(no changes)".to_string()
609    } else {
610        changes.join(", ")
611    };
612
613    output.success(format!(
614        "Set operation mapping for '{api_name}': '{op_id}' → {change_desc}"
615    ));
616    output.info("Run 'aperture config reinit' to apply changes.");
617    Ok(())
618}
619
620/// Handle the `config list-mappings` command
621pub fn handle_list_mappings(
622    manager: &ConfigManager<OsFileSystem>,
623    api_name: &crate::config::context_name::ApiContextName,
624    output: &Output,
625) -> Result<(), Error> {
626    let mapping = manager.get_command_mapping(api_name)?;
627    let Some(mapping) = mapping else {
628        output.info(format!(
629            "No command mappings configured for API '{api_name}'"
630        ));
631        return Ok(());
632    };
633
634    if mapping.groups.is_empty() && mapping.operations.is_empty() {
635        output.info(format!(
636            "No command mappings configured for API '{api_name}'"
637        ));
638        return Ok(());
639    }
640
641    output.info(format!("Command mappings for API '{api_name}':"));
642
643    if !mapping.groups.is_empty() {
644        // ast-grep-ignore: no-println
645        println!("\n  Group renames:");
646        for (original, new_name) in &mapping.groups {
647            // ast-grep-ignore: no-println
648            println!("    '{original}' → '{new_name}'");
649        }
650    }
651
652    if !mapping.operations.is_empty() {
653        // ast-grep-ignore: no-println
654        println!("\n  Operation mappings:");
655        for (op_id, op_mapping) in &mapping.operations {
656            print_operation_mapping(op_id, op_mapping);
657        }
658    }
659
660    Ok(())
661}
662
663/// Handle the `config remove-mapping` command
664pub fn handle_remove_mapping(
665    manager: &ConfigManager<OsFileSystem>,
666    api_name: &crate::config::context_name::ApiContextName,
667    group: Option<String>,
668    operation: Option<String>,
669    output: &Output,
670) -> Result<(), Error> {
671    match (group, operation) {
672        (Some(ref original), None) => {
673            manager.remove_group_mapping(api_name, original)?;
674            output.success(format!(
675                "Removed group mapping for tag '{original}' from API '{api_name}'"
676            ));
677        }
678        (None, Some(ref op_id)) => {
679            manager.remove_operation_mapping(api_name, op_id)?;
680            output.success(format!(
681                "Removed operation mapping for '{op_id}' from API '{api_name}'"
682            ));
683        }
684        (Some(_), Some(_)) => {
685            return Err(Error::invalid_config(
686                "Specify either --group or --operation, not both",
687            ));
688        }
689        (None, None) => {
690            return Err(Error::invalid_config(
691                "Either --group or --operation must be specified",
692            ));
693        }
694    }
695    output.info("Run 'aperture config reinit' to apply changes.");
696    Ok(())
697}
698
699/// Prints details of a single operation mapping
700fn print_operation_mapping(op_id: &str, op_mapping: &crate::config::models::OperationMapping) {
701    // ast-grep-ignore: no-println
702    println!("    {op_id}:");
703    if let Some(ref name) = op_mapping.name {
704        // ast-grep-ignore: no-println
705        println!("      name: {name}");
706    }
707    if let Some(ref group) = op_mapping.group {
708        // ast-grep-ignore: no-println
709        println!("      group: {group}");
710    }
711    if !op_mapping.aliases.is_empty() {
712        // ast-grep-ignore: no-println
713        println!("      aliases: {}", op_mapping.aliases.join(", "));
714    }
715    if op_mapping.hidden {
716        // ast-grep-ignore: no-println
717        println!("      hidden: true");
718    }
719}