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