Skip to main content

blz_cli/
lib.rs

1//! blz CLI - Fast local search for llms.txt documentation
2//!
3//! This is the main entry point for the blz command-line interface.
4//! All command implementations are organized in separate modules for
5//! better maintainability and single responsibility.
6use anyhow::{Result, anyhow};
7use blz_core::profiling::ResourceMonitor;
8use blz_core::{PerformanceMetrics, Storage};
9use clap::{CommandFactory, Parser};
10use colored::Colorize;
11use colored::control as color_control;
12use tracing::{Level, warn};
13use tracing_subscriber::FmtSubscriber;
14
15use std::sync::Arc;
16
17pub mod args;
18mod cli;
19mod commands;
20pub mod config;
21pub mod error;
22pub mod generate;
23mod output;
24mod prompt;
25mod utils;
26
27use crate::commands::{
28    AddRequest, BUNDLED_ALIAS, DescriptorInput, DocsSyncStatus, RequestSpec, print_full_content,
29    print_overview, sync_bundled_docs,
30};
31
32use crate::utils::preferences::{self, CliPreferences};
33use cli::{
34    AliasCommands, AnchorCommands, ClaudePluginCommands, Cli, Commands, DocsCommands,
35    DocsSearchArgs, RegistryCommands,
36};
37
38#[cfg(feature = "flamegraph")]
39use blz_core::profiling::{start_profiling, stop_profiling_and_report};
40
41/// Execute the blz CLI with the currently configured environment.
42///
43/// # Errors
44///
45/// Returns an error if CLI initialization, prompt emission, or command execution fails.
46pub async fn run() -> Result<()> {
47    // Convert Broken pipe panics into a clean exit
48    std::panic::set_hook(Box::new(|info| {
49        let msg = info.to_string();
50        if msg.contains("Broken pipe") || msg.contains("broken pipe") {
51            // Exit silently for pipeline truncation
52            std::process::exit(0);
53        }
54        // Default behavior: print to stderr
55        eprintln!("{msg}");
56    }));
57
58    // Spawn process guard as early as possible to catch orphaned processes
59    utils::process_guard::spawn_parent_exit_guard();
60
61    let mut cli = Cli::parse();
62
63    if let Some(target) = cli.prompt.clone() {
64        prompt::emit(&target, cli.command.as_ref())?;
65        return Ok(());
66    }
67
68    initialize_logging(&cli)?;
69
70    let args: Vec<String> = std::env::args().collect();
71    let mut cli_preferences = preferences::load();
72    apply_preference_defaults(&mut cli, &cli_preferences, &args);
73
74    let metrics = PerformanceMetrics::default();
75
76    // Create resource monitor early to capture baseline memory if profiling requested
77    let mut resource_monitor = if cli.profile {
78        Some(ResourceMonitor::new())
79    } else {
80        None
81    };
82
83    #[cfg(feature = "flamegraph")]
84    let profiler_guard = start_flamegraph_if_requested(&cli);
85
86    execute_command(cli.clone(), metrics.clone(), &mut cli_preferences).await?;
87
88    #[cfg(feature = "flamegraph")]
89    stop_flamegraph_if_started(profiler_guard);
90
91    print_diagnostics(&cli, &metrics, resource_monitor.as_mut());
92
93    if let Err(err) = preferences::save(&cli_preferences) {
94        warn!("failed to persist CLI preferences: {err}");
95    }
96
97    Ok(())
98}
99
100fn initialize_logging(cli: &Cli) -> Result<()> {
101    // Base level from global flags
102    let mut level = if cli.verbose || cli.debug {
103        Level::DEBUG
104    } else if cli.quiet {
105        Level::ERROR
106    } else {
107        Level::WARN
108    };
109
110    // If the selected command is emitting machine-readable output, suppress info logs
111    // to keep stdout/stderr clean unless verbose/debug was explicitly requested.
112    let mut machine_output = false;
113    if !(cli.verbose || cli.debug) {
114        #[allow(deprecated)]
115        let command_format = match &cli.command {
116            Some(
117                Commands::Search { format, .. }
118                | Commands::Find { format, .. }
119                | Commands::List { format, .. }
120                | Commands::Stats { format, .. }
121                | Commands::History { format, .. }
122                | Commands::Lookup { format, .. }
123                | Commands::Get { format, .. }
124                | Commands::Info { format, .. }
125                | Commands::Completions { format, .. },
126            ) => Some(format.resolve(cli.quiet)),
127            Some(Commands::Query(args)) => Some(args.format.resolve(cli.quiet)),
128            Some(Commands::Map(args)) => Some(args.format.resolve(cli.quiet)),
129            Some(Commands::Check(args)) => Some(args.format.resolve(cli.quiet)),
130            _ => None,
131        };
132
133        if let Some(fmt) = command_format {
134            if matches!(
135                fmt,
136                crate::output::OutputFormat::Json | crate::output::OutputFormat::Jsonl
137            ) {
138                level = Level::ERROR;
139                machine_output = true;
140            }
141        }
142    }
143
144    let subscriber = FmtSubscriber::builder()
145        .with_max_level(level)
146        .with_target(false)
147        .with_thread_ids(false)
148        .with_thread_names(false)
149        .with_writer(std::io::stderr)
150        .finish();
151
152    tracing::subscriber::set_global_default(subscriber)?;
153
154    // Color control: disable when requested, NO_COLOR is set, or when emitting machine output
155    let env_no_color = std::env::var("NO_COLOR").ok().is_some();
156    if cli.no_color || env_no_color || machine_output {
157        color_control::set_override(false);
158    }
159    Ok(())
160}
161
162#[cfg(feature = "flamegraph")]
163fn start_flamegraph_if_requested(cli: &Cli) -> Option<pprof::ProfilerGuard<'static>> {
164    if cli.flamegraph {
165        match start_profiling() {
166            Ok(guard) => {
167                println!("🔥 CPU profiling started - flamegraph will be generated");
168                Some(guard)
169            },
170            Err(e) => {
171                eprintln!("Failed to start profiling: {e}");
172                None
173            },
174        }
175    } else {
176        None
177    }
178}
179
180#[cfg(feature = "flamegraph")]
181fn stop_flamegraph_if_started(guard: Option<pprof::ProfilerGuard<'static>>) {
182    if let Some(guard) = guard {
183        if let Err(e) = stop_profiling_and_report(&guard) {
184            eprintln!("Failed to generate flamegraph: {e}");
185        }
186    }
187}
188
189async fn execute_command(
190    cli: Cli,
191    metrics: PerformanceMetrics,
192    prefs: &mut CliPreferences,
193) -> Result<()> {
194    let quiet = cli.quiet;
195    match cli.command {
196        Some(Commands::Instruct) => {
197            prompt::emit("__global__", Some(&Commands::Instruct))?;
198            eprintln!("`blz instruct` is deprecated. Use `blz --prompt` instead.");
199        },
200        Some(Commands::Completions {
201            shell,
202            list,
203            format,
204        }) => {
205            handle_completions(shell, list, format.resolve(quiet));
206        },
207        Some(Commands::Docs { command }) => {
208            handle_docs(command, quiet, metrics.clone(), prefs).await?;
209        },
210        Some(Commands::ClaudePlugin { command }) => handle_claude_plugin(command)?,
211        Some(Commands::Alias { command }) => handle_alias(command).await?,
212        Some(Commands::Add(args)) => handle_add(args, quiet, metrics).await?,
213        Some(Commands::Lookup {
214            query,
215            format,
216            limit,
217        }) => {
218            dispatch_lookup(query, format, limit, quiet, metrics).await?;
219        },
220        Some(Commands::Registry { command }) => handle_registry(command, quiet, metrics).await?,
221        Some(cmd @ Commands::Search { .. }) => dispatch_search(cmd, quiet, metrics, prefs).await?,
222        Some(Commands::History {
223            limit,
224            format,
225            clear,
226            clear_before,
227        }) => {
228            dispatch_history(limit, &format, clear, clear_before.as_deref(), quiet, prefs)?;
229        },
230        Some(cmd @ Commands::Get { .. }) => dispatch_get(cmd, quiet).await?,
231        Some(Commands::Query(args)) => handle_query(args, quiet, prefs, metrics.clone()).await?,
232        Some(Commands::Map(args)) => dispatch_map(args, quiet).await?,
233        Some(Commands::Sync(args)) => dispatch_sync(args, quiet, metrics).await?,
234        Some(Commands::Check(args)) => {
235            commands::check_source(args.alias, args.all, args.format.resolve(quiet)).await?;
236        },
237        Some(Commands::Rm(args)) => commands::rm_source(vec![args.alias], args.yes).await?,
238        #[allow(deprecated)]
239        Some(cmd @ Commands::Find { .. }) => {
240            dispatch_find(cmd, quiet, prefs, metrics.clone()).await?;
241        },
242        Some(Commands::Info { alias, format }) => {
243            commands::execute_info(&alias, format.resolve(quiet)).await?;
244        },
245        Some(Commands::List {
246            format,
247            status,
248            details,
249            limit,
250        }) => {
251            dispatch_list(format, status, details, limit, quiet).await?;
252        },
253        Some(Commands::Stats { format, limit }) => {
254            commands::show_stats(format.resolve(quiet), limit)?;
255        },
256        #[allow(deprecated)]
257        Some(Commands::Validate { alias, all, format }) => {
258            dispatch_validate(alias, all, format, quiet).await?;
259        },
260        Some(Commands::Doctor { format, fix }) => {
261            commands::run_doctor(format.resolve(quiet), fix).await?;
262        },
263        #[allow(deprecated)]
264        Some(cmd @ Commands::Refresh { .. }) => dispatch_refresh(cmd, quiet, metrics).await?,
265        #[allow(deprecated)]
266        Some(cmd @ Commands::Update { .. }) => dispatch_update(cmd, quiet, metrics).await?,
267        #[allow(deprecated)]
268        Some(Commands::Remove { alias, yes }) => dispatch_remove(alias, yes, quiet).await?,
269        Some(Commands::Clear { force }) => commands::clear_cache(force)?,
270        Some(Commands::Diff { alias, since }) => {
271            commands::show_diff(&alias, since.as_deref()).await?;
272        },
273        Some(Commands::McpServer) => commands::mcp_server().await?,
274        Some(Commands::Anchor { command }) => handle_anchor(command, quiet).await?,
275        #[allow(deprecated)]
276        Some(cmd @ Commands::Toc { .. }) => dispatch_toc(cmd, quiet).await?,
277        None => {
278            // No subcommand provided - show help
279            Cli::command().print_help()?;
280        },
281    }
282    Ok(())
283}
284
285/// Dispatch a Search command variant, handling destructuring internally.
286///
287/// This function extracts all fields from the `Commands::Search` variant and
288/// forwards them to the underlying `handle_search` implementation.
289#[allow(clippy::too_many_lines)]
290async fn dispatch_search(
291    cmd: Commands,
292    quiet: bool,
293    metrics: PerformanceMetrics,
294    prefs: &mut CliPreferences,
295) -> Result<()> {
296    let Commands::Search {
297        query,
298        sources,
299        next,
300        previous,
301        last,
302        limit,
303        all,
304        page,
305        top,
306        heading_level,
307        format,
308        show,
309        no_summary,
310        score_precision,
311        snippet_lines,
312        max_chars,
313        context,
314        context_deprecated,
315        after_context,
316        before_context,
317        block,
318        max_lines,
319        headings_only,
320        no_history,
321        copy,
322        timing,
323    } = cmd
324    else {
325        unreachable!("dispatch_search called with non-Search command");
326    };
327
328    let resolved_format = format.resolve(quiet);
329    let merged_context =
330        crate::cli::merge_context_flags(context, context_deprecated, after_context, before_context);
331
332    handle_search(
333        query,
334        sources,
335        next,
336        previous,
337        last,
338        limit,
339        all,
340        page,
341        top,
342        heading_level,
343        resolved_format,
344        show,
345        no_summary,
346        score_precision,
347        snippet_lines,
348        max_chars,
349        merged_context,
350        block,
351        max_lines,
352        headings_only,
353        no_history,
354        copy,
355        timing,
356        quiet,
357        metrics,
358        prefs,
359    )
360    .await
361}
362
363/// Dispatch a Get command variant, handling destructuring internally.
364#[allow(clippy::too_many_lines)]
365async fn dispatch_get(cmd: Commands, quiet: bool) -> Result<()> {
366    let Commands::Get {
367        targets,
368        lines,
369        source,
370        context,
371        context_deprecated,
372        after_context,
373        before_context,
374        block,
375        max_lines,
376        format,
377        copy,
378    } = cmd
379    else {
380        unreachable!("dispatch_get called with non-Get command");
381    };
382
383    handle_get(
384        targets,
385        lines,
386        source,
387        context,
388        context_deprecated,
389        after_context,
390        before_context,
391        block,
392        max_lines,
393        format.resolve(quiet),
394        copy,
395    )
396    .await
397}
398
399/// Dispatch a Find command variant, handling destructuring internally.
400#[allow(clippy::too_many_lines)]
401async fn dispatch_find(
402    cmd: Commands,
403    quiet: bool,
404    prefs: &mut CliPreferences,
405    metrics: PerformanceMetrics,
406) -> Result<()> {
407    #[allow(deprecated)]
408    let Commands::Find {
409        inputs,
410        sources,
411        limit,
412        all,
413        page,
414        top,
415        heading_level,
416        format,
417        show,
418        no_summary,
419        score_precision,
420        snippet_lines,
421        max_chars,
422        context,
423        context_deprecated,
424        after_context,
425        before_context,
426        block,
427        max_lines,
428        headings_only,
429        no_history,
430        copy,
431        timing,
432    } = cmd
433    else {
434        unreachable!("dispatch_find called with non-Find command");
435    };
436
437    handle_find(
438        inputs,
439        sources,
440        limit,
441        all,
442        page,
443        top,
444        heading_level,
445        format.resolve(quiet),
446        show,
447        no_summary,
448        score_precision,
449        snippet_lines,
450        max_chars,
451        context,
452        context_deprecated,
453        after_context,
454        before_context,
455        block,
456        max_lines,
457        headings_only,
458        no_history,
459        copy,
460        timing,
461        quiet,
462        prefs,
463        metrics,
464    )
465    .await
466}
467
468/// Dispatch a Toc command variant, handling destructuring internally.
469#[allow(deprecated)]
470async fn dispatch_toc(cmd: Commands, quiet: bool) -> Result<()> {
471    let Commands::Toc {
472        alias,
473        format,
474        filter,
475        max_depth,
476        heading_level,
477        sources,
478        all,
479        tree,
480        anchors,
481        show_anchors,
482        next,
483        previous,
484        last,
485        limit,
486        page,
487    } = cmd
488    else {
489        unreachable!("dispatch_toc called with non-Toc command");
490    };
491
492    handle_toc(
493        alias,
494        sources,
495        all,
496        format.resolve(quiet),
497        anchors,
498        show_anchors,
499        limit,
500        max_depth,
501        heading_level,
502        filter,
503        tree,
504        next,
505        previous,
506        last,
507        page,
508    )
509    .await
510}
511
512/// Dispatch a Refresh command variant, handling destructuring internally.
513#[allow(deprecated)]
514async fn dispatch_refresh(cmd: Commands, quiet: bool, metrics: PerformanceMetrics) -> Result<()> {
515    let Commands::Refresh {
516        aliases,
517        all,
518        yes: _,
519        reindex,
520        filter,
521        no_filter,
522    } = cmd
523    else {
524        unreachable!("dispatch_refresh called with non-Refresh command");
525    };
526
527    if !utils::cli_args::deprecation_warnings_suppressed() {
528        eprintln!(
529            "{}",
530            "Warning: 'refresh' is deprecated, use 'sync' instead".yellow()
531        );
532    }
533
534    handle_refresh(aliases, all, reindex, filter, no_filter, metrics, quiet).await
535}
536
537/// Dispatch a Map command.
538async fn dispatch_map(args: crate::cli::MapArgs, quiet: bool) -> Result<()> {
539    commands::show_map(
540        args.alias.as_deref(),
541        &args.sources,
542        args.all,
543        args.format.resolve(quiet),
544        args.anchors,
545        args.show_anchors,
546        args.limit,
547        args.max_depth,
548        args.heading_level.as_ref(),
549        args.filter.as_deref(),
550        args.tree,
551        args.next,
552        args.previous,
553        args.last,
554        args.page,
555    )
556    .await
557}
558
559/// Dispatch a Sync command.
560async fn dispatch_sync(
561    args: crate::cli::SyncArgs,
562    quiet: bool,
563    metrics: PerformanceMetrics,
564) -> Result<()> {
565    commands::sync_source(
566        args.aliases,
567        args.all,
568        args.yes,
569        args.reindex,
570        args.filter,
571        args.no_filter,
572        metrics,
573        quiet,
574    )
575    .await
576}
577
578/// Dispatch a History command.
579fn dispatch_history(
580    limit: usize,
581    format: &crate::utils::cli_args::FormatArg,
582    clear: bool,
583    clear_before: Option<&str>,
584    quiet: bool,
585    prefs: &CliPreferences,
586) -> Result<()> {
587    commands::show_history(prefs, limit, format.resolve(quiet), clear, clear_before)
588}
589
590/// Dispatch a List command.
591async fn dispatch_list(
592    format: crate::utils::cli_args::FormatArg,
593    status: bool,
594    details: bool,
595    limit: Option<usize>,
596    quiet: bool,
597) -> Result<()> {
598    commands::list_sources(format.resolve(quiet), status, details, limit).await
599}
600
601/// Dispatch a Validate command (deprecated).
602#[allow(deprecated)]
603async fn dispatch_validate(
604    alias: Option<String>,
605    all: bool,
606    format: crate::utils::cli_args::FormatArg,
607    quiet: bool,
608) -> Result<()> {
609    if !utils::cli_args::deprecation_warnings_suppressed() {
610        eprintln!(
611            "{}",
612            "Warning: 'validate' is deprecated, use 'check' instead".yellow()
613        );
614    }
615    commands::validate_source(alias, all, format.resolve(quiet)).await
616}
617
618/// Dispatch an Update command (deprecated).
619#[allow(deprecated)]
620async fn dispatch_update(cmd: Commands, quiet: bool, metrics: PerformanceMetrics) -> Result<()> {
621    let Commands::Update {
622        aliases,
623        all,
624        yes: _,
625    } = cmd
626    else {
627        unreachable!("dispatch_update called with non-Update command");
628    };
629
630    if !utils::cli_args::deprecation_warnings_suppressed() {
631        eprintln!(
632            "{}",
633            "Warning: 'update' is deprecated, use 'refresh' instead".yellow()
634        );
635    }
636    handle_refresh(aliases, all, false, None, false, metrics, quiet).await
637}
638
639/// Dispatch a Remove command (deprecated).
640#[allow(deprecated)]
641async fn dispatch_remove(alias: String, yes: bool, quiet: bool) -> Result<()> {
642    if !utils::cli_args::deprecation_warnings_suppressed() {
643        eprintln!(
644            "{}",
645            "Warning: 'remove' is deprecated, use 'rm' instead".yellow()
646        );
647    }
648    commands::remove_source(&alias, yes, quiet).await
649}
650
651/// Dispatch a Lookup command.
652async fn dispatch_lookup(
653    query: String,
654    format: crate::utils::cli_args::FormatArg,
655    limit: Option<usize>,
656    quiet: bool,
657    metrics: PerformanceMetrics,
658) -> Result<()> {
659    commands::lookup_registry(&query, metrics, quiet, format.resolve(quiet), limit).await
660}
661
662fn handle_completions(
663    shell: Option<clap_complete::Shell>,
664    list: bool,
665    format: crate::output::OutputFormat,
666) {
667    if list {
668        commands::list_supported(format);
669    } else if let Some(shell) = shell {
670        commands::generate(shell);
671    } else {
672        commands::list_supported(format);
673    }
674}
675
676async fn handle_add(
677    args: crate::cli::AddArgs,
678    quiet: bool,
679    metrics: PerformanceMetrics,
680) -> Result<()> {
681    if let Some(manifest) = &args.manifest {
682        commands::add_manifest(
683            manifest,
684            &args.only,
685            metrics,
686            commands::AddFlowOptions::new(args.dry_run, quiet, args.no_language_filter),
687        )
688        .await
689    } else {
690        let alias = args
691            .alias
692            .as_deref()
693            .ok_or_else(|| anyhow!("alias is required when manifest is not provided"))?;
694        let url = args
695            .url
696            .as_deref()
697            .ok_or_else(|| anyhow!("url is required when manifest is not provided"))?;
698
699        let descriptor = DescriptorInput::from_cli_inputs(
700            &args.aliases,
701            args.name.as_deref(),
702            args.description.as_deref(),
703            args.category.as_deref(),
704            &args.tags,
705        );
706
707        let request = AddRequest::new(
708            alias.to_string(),
709            url.to_string(),
710            descriptor,
711            args.dry_run,
712            quiet,
713            metrics,
714            args.no_language_filter,
715        );
716
717        commands::add_source(request).await
718    }
719}
720
721#[allow(clippy::too_many_arguments)]
722async fn handle_get(
723    targets: Vec<String>,
724    lines: Option<String>,
725    source: Option<String>,
726    context: Option<crate::cli::ContextMode>,
727    context_deprecated: Option<crate::cli::ContextMode>,
728    after_context: Option<usize>,
729    before_context: Option<usize>,
730    block: bool,
731    max_lines: Option<usize>,
732    format: crate::output::OutputFormat,
733    copy: bool,
734) -> Result<()> {
735    let request_specs = parse_get_targets(&targets, lines.as_deref(), source)?;
736
737    let merged_context =
738        crate::cli::merge_context_flags(context, context_deprecated, after_context, before_context);
739
740    commands::get_lines(
741        &request_specs,
742        merged_context.as_ref(),
743        block,
744        max_lines,
745        format,
746        copy,
747    )
748    .await
749}
750
751/// Parse get command targets into request specifications.
752fn parse_get_targets(
753    targets: &[String],
754    lines: Option<&str>,
755    source: Option<String>,
756) -> Result<Vec<RequestSpec>> {
757    if targets.is_empty() {
758        anyhow::bail!("At least one target is required. Use format: alias[:ranges]");
759    }
760
761    if lines.is_some() && targets.len() > 1 {
762        anyhow::bail!(
763            "--lines can only be combined with a single alias. \
764             Provide explicit ranges via colon syntax for each additional target."
765        );
766    }
767
768    let mut request_specs = Vec::with_capacity(targets.len());
769    for (idx, target) in targets.iter().enumerate() {
770        let spec = parse_single_target(target, idx, lines)?;
771        request_specs.push(spec);
772    }
773
774    apply_source_override(&mut request_specs, source)?;
775    Ok(request_specs)
776}
777
778/// Parse a single target string into a `RequestSpec`.
779fn parse_single_target(target: &str, idx: usize, lines: Option<&str>) -> Result<RequestSpec> {
780    let trimmed = target.trim();
781    if trimmed.is_empty() {
782        anyhow::bail!("Alias at position {} cannot be empty.", idx + 1);
783    }
784
785    if let Some((alias_part, range_part)) = trimmed.split_once(':') {
786        let trimmed_alias = alias_part.trim();
787        if trimmed_alias.is_empty() {
788            anyhow::bail!(
789                "Alias at position {} cannot be empty. Use syntax like 'bun:120-142'.",
790                idx + 1
791            );
792        }
793        if range_part.is_empty() {
794            anyhow::bail!(
795                "Alias '{trimmed_alias}' is missing a range. \
796                 Use syntax like '{trimmed_alias}:120-142'."
797            );
798        }
799        Ok(RequestSpec {
800            alias: trimmed_alias.to_string(),
801            line_expression: range_part.trim().to_string(),
802        })
803    } else {
804        let Some(line_expr) = lines else {
805            anyhow::bail!(
806                "Missing line specification for alias '{trimmed}'. \
807                 Use '{trimmed}:1-3' or provide --lines."
808            );
809        };
810        Ok(RequestSpec {
811            alias: trimmed.to_string(),
812            line_expression: line_expr.to_string(),
813        })
814    }
815}
816
817/// Apply source override to request specs if provided.
818fn apply_source_override(request_specs: &mut [RequestSpec], source: Option<String>) -> Result<()> {
819    if let Some(explicit_source) = source {
820        if request_specs.len() > 1 {
821            anyhow::bail!("--source cannot be combined with multiple alias targets.");
822        }
823        if let Some(first) = request_specs.first_mut() {
824            first.alias = explicit_source;
825        }
826    }
827    Ok(())
828}
829
830async fn handle_query(
831    args: crate::cli::QueryArgs,
832    quiet: bool,
833    prefs: &mut CliPreferences,
834    metrics: PerformanceMetrics,
835) -> Result<()> {
836    let resolved_format = args.format.resolve(quiet);
837    let merged_context = crate::cli::merge_context_flags(
838        args.context,
839        args.context_deprecated,
840        args.after_context,
841        args.before_context,
842    );
843
844    commands::query(
845        &args.inputs,
846        &args.sources,
847        args.limit,
848        args.all,
849        args.page,
850        false, // last - query command doesn't support --last flag
851        args.top,
852        args.heading_level.clone(),
853        resolved_format,
854        &args.show,
855        args.no_summary,
856        args.score_precision,
857        args.snippet_lines,
858        args.max_chars,
859        merged_context.as_ref(),
860        args.block,
861        args.max_lines,
862        args.no_history,
863        args.copy,
864        quiet,
865        args.headings_only,
866        args.timing,
867        Some(prefs),
868        metrics,
869        None,
870    )
871    .await
872}
873
874#[allow(clippy::too_many_arguments, clippy::fn_params_excessive_bools)]
875async fn handle_find(
876    inputs: Vec<String>,
877    sources: Vec<String>,
878    limit: Option<usize>,
879    all: bool,
880    page: usize,
881    top: Option<u8>,
882    heading_level: Option<String>,
883    format: crate::output::OutputFormat,
884    show: Vec<crate::cli::ShowComponent>,
885    no_summary: bool,
886    score_precision: Option<u8>,
887    snippet_lines: u8,
888    max_chars: Option<usize>,
889    context: Option<crate::cli::ContextMode>,
890    context_deprecated: Option<crate::cli::ContextMode>,
891    after_context: Option<usize>,
892    before_context: Option<usize>,
893    block: bool,
894    max_lines: Option<usize>,
895    headings_only: bool,
896    no_history: bool,
897    copy: bool,
898    timing: bool,
899    quiet: bool,
900    prefs: &mut CliPreferences,
901    metrics: PerformanceMetrics,
902) -> Result<()> {
903    if !utils::cli_args::deprecation_warnings_suppressed() {
904        eprintln!(
905            "{}",
906            "Warning: 'find' is deprecated, use 'query' or 'get' instead".yellow()
907        );
908    }
909
910    let merged_context =
911        crate::cli::merge_context_flags(context, context_deprecated, after_context, before_context);
912
913    commands::find(
914        &inputs,
915        &sources,
916        limit,
917        all,
918        page,
919        false, // last - find command doesn't support --last flag
920        top,
921        heading_level,
922        format,
923        &show,
924        no_summary,
925        score_precision,
926        snippet_lines,
927        max_chars,
928        merged_context.as_ref(),
929        block,
930        max_lines,
931        no_history,
932        copy,
933        quiet,
934        headings_only,
935        timing,
936        Some(prefs),
937        metrics,
938        None,
939    )
940    .await
941}
942
943#[allow(clippy::too_many_arguments, clippy::fn_params_excessive_bools)]
944async fn handle_toc(
945    alias: Option<String>,
946    sources: Vec<String>,
947    all: bool,
948    format: crate::output::OutputFormat,
949    anchors: bool,
950    show_anchors: bool,
951    limit: Option<usize>,
952    max_depth: Option<u8>,
953    heading_level: Option<crate::utils::heading_filter::HeadingLevelFilter>,
954    filter: Option<String>,
955    tree: bool,
956    next: bool,
957    previous: bool,
958    last: bool,
959    page: usize,
960) -> Result<()> {
961    if !utils::cli_args::deprecation_warnings_suppressed() {
962        eprintln!(
963            "{}",
964            "Warning: 'toc' is deprecated, use 'map' instead".yellow()
965        );
966    }
967
968    commands::show_toc(
969        alias.as_deref(),
970        &sources,
971        all,
972        format,
973        anchors,
974        show_anchors,
975        limit,
976        max_depth,
977        heading_level.as_ref(),
978        filter.as_deref(),
979        tree,
980        next,
981        previous,
982        last,
983        page,
984    )
985    .await
986}
987
988async fn handle_docs(
989    command: Option<DocsCommands>,
990    quiet: bool,
991    metrics: PerformanceMetrics,
992    _prefs: &mut CliPreferences,
993) -> Result<()> {
994    match command {
995        Some(DocsCommands::Search(args)) => docs_search(args, quiet, metrics.clone()).await?,
996        Some(DocsCommands::Sync {
997            force,
998            quiet: sync_quiet,
999        }) => docs_sync(force, sync_quiet, metrics.clone())?,
1000        Some(DocsCommands::Overview) => {
1001            docs_overview(quiet, metrics.clone())?;
1002        },
1003        Some(DocsCommands::Cat) => {
1004            docs_cat(metrics.clone())?;
1005        },
1006        Some(DocsCommands::Export { format }) => {
1007            docs_export(Some(format))?;
1008        },
1009        None => {
1010            // When no subcommand is provided, show overview
1011            docs_overview(quiet, metrics.clone())?;
1012        },
1013    }
1014
1015    Ok(())
1016}
1017
1018fn handle_claude_plugin(command: ClaudePluginCommands) -> Result<()> {
1019    match command {
1020        ClaudePluginCommands::Install { scope } => {
1021            commands::install_local_plugin(scope)?;
1022        },
1023    }
1024
1025    Ok(())
1026}
1027
1028async fn docs_search(args: DocsSearchArgs, quiet: bool, metrics: PerformanceMetrics) -> Result<()> {
1029    sync_and_report(false, quiet, metrics.clone())?;
1030    let query = args.query.join(" ").trim().to_string();
1031    if query.is_empty() {
1032        anyhow::bail!("Search query cannot be empty");
1033    }
1034
1035    // Resolve format once before checking placeholder content
1036    let format = args.format.resolve(quiet);
1037
1038    // Check if the bundled docs contain placeholder content
1039    let storage = Storage::new()?;
1040    if let Ok(content_path) = storage.llms_txt_path(BUNDLED_ALIAS) {
1041        if let Ok(content) = std::fs::read_to_string(&content_path) {
1042            if content.contains("# BLZ bundled docs (placeholder)") {
1043                let error_msg = if matches!(format, crate::output::OutputFormat::Json) {
1044                    // JSON output: structured error message
1045                    let error_json = serde_json::json!({
1046                        "error": "Bundled documentation content not yet available",
1047                        "reason": "The blz-docs source currently contains placeholder content",
1048                        "suggestions": [
1049                            "Use 'blz docs overview' for quick-start information",
1050                            "Use 'blz docs export' to view CLI documentation",
1051                            "Full bundled documentation will be included in a future release"
1052                        ]
1053                    });
1054                    return Err(anyhow!(serde_json::to_string_pretty(&error_json)?));
1055                } else {
1056                    // Text output: user-friendly message
1057                    "Bundled documentation content not yet available.\n\
1058                     \n\
1059                     The blz-docs source currently contains placeholder content.\n\
1060                     Full documentation will be included in a future release.\n\
1061                     \n\
1062                     Available alternatives:\n\
1063                     • Run 'blz docs overview' for quick-start information\n\
1064                     • Run 'blz docs export' to view CLI documentation\n\
1065                     • Run 'blz docs cat' to view the current placeholder content"
1066                };
1067                anyhow::bail!("{error_msg}");
1068            }
1069        }
1070    }
1071
1072    let sources = vec![BUNDLED_ALIAS.to_string()];
1073
1074    // Convert docs search args context to ContextMode
1075    let context_mode = args.context.map(crate::cli::ContextMode::Symmetric);
1076
1077    commands::search(
1078        &query,
1079        &sources,
1080        false,
1081        args.limit,
1082        1,
1083        args.top,
1084        None, // heading_level - not supported in bare command mode
1085        format,
1086        &args.show,
1087        args.no_summary,
1088        args.score_precision,
1089        args.snippet_lines,
1090        args.max_chars.unwrap_or(commands::DEFAULT_MAX_CHARS),
1091        context_mode.as_ref(),
1092        args.block,
1093        args.max_block_lines,
1094        false,
1095        true,
1096        args.copy,
1097        false, // timing
1098        quiet,
1099        None,
1100        metrics,
1101        None,
1102        false, // no deprecation warning - `blz docs search` is the intended command
1103    )
1104    .await
1105}
1106
1107fn docs_sync(force: bool, quiet: bool, metrics: PerformanceMetrics) -> Result<()> {
1108    let status = sync_and_report(force, quiet, metrics)?;
1109    if !quiet && matches!(status, DocsSyncStatus::Installed | DocsSyncStatus::Updated) {
1110        let storage = Storage::new()?;
1111        let llms_path = storage.llms_txt_path(BUNDLED_ALIAS)?;
1112        println!("Bundled docs stored at {}", llms_path.display());
1113    }
1114    Ok(())
1115}
1116
1117fn docs_overview(quiet: bool, metrics: PerformanceMetrics) -> Result<()> {
1118    let status = sync_and_report(false, quiet, metrics)?;
1119    if !quiet {
1120        let storage = Storage::new()?;
1121        let llms_path = storage.llms_txt_path(BUNDLED_ALIAS)?;
1122        println!("Bundled docs status: {status:?}");
1123        println!("Alias: {BUNDLED_ALIAS} (also @blz)");
1124        println!("Stored at: {}", llms_path.display());
1125    }
1126    print_overview();
1127    Ok(())
1128}
1129
1130fn docs_cat(metrics: PerformanceMetrics) -> Result<()> {
1131    sync_and_report(false, true, metrics)?;
1132    print_full_content();
1133    Ok(())
1134}
1135
1136fn docs_export(format: Option<crate::commands::DocsFormat>) -> Result<()> {
1137    let requested = format.unwrap_or(crate::commands::DocsFormat::Markdown);
1138    let effective = match (std::env::var("BLZ_OUTPUT_FORMAT").ok(), requested) {
1139        (Some(v), crate::commands::DocsFormat::Markdown) if v.eq_ignore_ascii_case("json") => {
1140            crate::commands::DocsFormat::Json
1141        },
1142        _ => requested,
1143    };
1144    commands::generate_docs(effective)
1145}
1146
1147fn sync_and_report(
1148    force: bool,
1149    quiet: bool,
1150    metrics: PerformanceMetrics,
1151) -> Result<DocsSyncStatus> {
1152    let status = sync_bundled_docs(force, metrics)?;
1153    if !quiet {
1154        match status {
1155            DocsSyncStatus::UpToDate => {
1156                println!("Bundled docs already up to date");
1157            },
1158            DocsSyncStatus::Installed => {
1159                println!("Installed bundled docs source: {BUNDLED_ALIAS}");
1160            },
1161            DocsSyncStatus::Updated => {
1162                println!("Updated bundled docs source: {BUNDLED_ALIAS}");
1163            },
1164        }
1165    }
1166    Ok(status)
1167}
1168
1169async fn handle_anchor(command: AnchorCommands, quiet: bool) -> Result<()> {
1170    match command {
1171        AnchorCommands::List {
1172            alias,
1173            format,
1174            anchors,
1175            limit,
1176            max_depth,
1177            filter,
1178        } => {
1179            commands::show_toc(
1180                Some(&alias),
1181                &[],
1182                false,
1183                format.resolve(quiet),
1184                anchors,
1185                false, // show_anchors - not applicable in anchor list mode
1186                limit,
1187                max_depth,
1188                None,
1189                filter.as_deref(),
1190                false,
1191                false, // next
1192                false, // previous
1193                false, // last
1194                1,     // page
1195            )
1196            .await
1197        },
1198        AnchorCommands::Get {
1199            alias,
1200            anchor,
1201            context,
1202            format,
1203        } => commands::get_by_anchor(&alias, &anchor, context, format.resolve(quiet)).await,
1204    }
1205}
1206
1207async fn handle_alias(command: AliasCommands) -> Result<()> {
1208    match command {
1209        AliasCommands::Add { source, alias } => {
1210            commands::manage_alias(commands::AliasCommand::Add { source, alias }).await
1211        },
1212        AliasCommands::Rm { source, alias } => {
1213            commands::manage_alias(commands::AliasCommand::Rm { source, alias }).await
1214        },
1215    }
1216}
1217
1218async fn handle_registry(
1219    command: RegistryCommands,
1220    quiet: bool,
1221    metrics: PerformanceMetrics,
1222) -> Result<()> {
1223    match command {
1224        RegistryCommands::CreateSource {
1225            name,
1226            url,
1227            description,
1228            category,
1229            tags,
1230            npm,
1231            github,
1232            add,
1233            yes,
1234        } => {
1235            commands::create_registry_source(
1236                &name,
1237                &url,
1238                description,
1239                category,
1240                tags,
1241                npm,
1242                github,
1243                add,
1244                yes,
1245                quiet,
1246                metrics,
1247            )
1248            .await
1249        },
1250    }
1251}
1252
1253#[allow(clippy::too_many_arguments, clippy::fn_params_excessive_bools)]
1254async fn handle_search(
1255    query: Option<String>,
1256    sources: Vec<String>,
1257    next: bool,
1258    previous: bool,
1259    last: bool,
1260    limit: Option<usize>,
1261    all: bool,
1262    page: usize,
1263    top: Option<u8>,
1264    heading_level: Option<String>,
1265    format: crate::output::OutputFormat,
1266    show: Vec<crate::cli::ShowComponent>,
1267    no_summary: bool,
1268    score_precision: Option<u8>,
1269    snippet_lines: u8,
1270    max_chars: Option<usize>,
1271    context: Option<crate::cli::ContextMode>,
1272    block: bool,
1273    max_lines: Option<usize>,
1274    headings_only: bool,
1275    no_history: bool,
1276    copy: bool,
1277    timing: bool,
1278    quiet: bool,
1279    metrics: PerformanceMetrics,
1280    prefs: &mut CliPreferences,
1281) -> Result<()> {
1282    const DEFAULT_LIMIT: usize = 50;
1283    const ALL_RESULTS_LIMIT: usize = 10_000;
1284    const DEFAULT_SNIPPET_LINES: u8 = 3;
1285
1286    let provided_query = query.is_some();
1287    let limit_was_explicit = all || limit.is_some();
1288    let mut use_headings_only = headings_only;
1289
1290    // Emit deprecation warning if --snippet-lines was explicitly set
1291    if snippet_lines != DEFAULT_SNIPPET_LINES {
1292        let args: Vec<String> = std::env::args().collect();
1293        if flag_present(&args, "--snippet-lines") || std::env::var("BLZ_SNIPPET_LINES").is_ok() {
1294            // Pass false for quiet - the deprecation function handles quiet mode internally
1295            utils::cli_args::emit_snippet_lines_deprecation(false);
1296        }
1297    }
1298
1299    if next {
1300        validate_continuation_flag("--next", provided_query, &sources, page, last)?;
1301    }
1302
1303    if previous {
1304        validate_continuation_flag("--previous", provided_query, &sources, page, last)?;
1305    }
1306
1307    let history_entry = if next || previous || !provided_query {
1308        let mut records = utils::history_log::recent_for_active_scope(1);
1309        if records.is_empty() {
1310            anyhow::bail!("No previous search found. Use 'blz search <query>' first.");
1311        }
1312        Some(records.remove(0))
1313    } else {
1314        None
1315    };
1316
1317    if let Some(entry) = history_entry.as_ref() {
1318        if (next || previous) && headings_only != entry.headings_only {
1319            anyhow::bail!(
1320                "Cannot change --headings-only while using --next/--previous. Rerun without continuation flags."
1321            );
1322        }
1323        if !headings_only {
1324            use_headings_only = entry.headings_only;
1325        }
1326    }
1327
1328    let actual_query = resolve_query(query, history_entry.as_ref())?;
1329    let actual_sources = resolve_sources(sources, history_entry.as_ref());
1330
1331    let base_limit = if all {
1332        ALL_RESULTS_LIMIT
1333    } else {
1334        limit.unwrap_or(DEFAULT_LIMIT)
1335    };
1336    let actual_max_chars = max_chars.map_or(commands::DEFAULT_MAX_CHARS, commands::clamp_max_chars);
1337
1338    let (actual_page, actual_limit) = if let Some(entry) = history_entry.as_ref() {
1339        let adj = apply_history_pagination(
1340            entry,
1341            next,
1342            previous,
1343            provided_query,
1344            all,
1345            limit,
1346            limit_was_explicit,
1347            page,
1348            base_limit,
1349            ALL_RESULTS_LIMIT,
1350        )?;
1351        (adj.page, adj.limit)
1352    } else {
1353        (page, base_limit)
1354    };
1355
1356    commands::search(
1357        &actual_query,
1358        &actual_sources,
1359        last,
1360        actual_limit,
1361        actual_page,
1362        top,
1363        heading_level.clone(),
1364        format,
1365        &show,
1366        no_summary,
1367        score_precision,
1368        snippet_lines,
1369        actual_max_chars,
1370        context.as_ref(),
1371        block,
1372        max_lines,
1373        use_headings_only,
1374        no_history,
1375        copy,
1376        timing,
1377        quiet,
1378        Some(prefs),
1379        metrics,
1380        None,
1381        true, // emit deprecation warning - this is the deprecated `blz search` command
1382    )
1383    .await
1384}
1385
1386#[allow(clippy::fn_params_excessive_bools)]
1387async fn handle_refresh(
1388    aliases: Vec<String>,
1389    all: bool,
1390    reindex: bool,
1391    filter: Option<String>,
1392    no_filter: bool,
1393    metrics: PerformanceMetrics,
1394    quiet: bool,
1395) -> Result<()> {
1396    let mut aliases = aliases;
1397    let mut filter = filter;
1398
1399    if !all && aliases.is_empty() {
1400        if let Some(raw_value) = filter.take() {
1401            if crate::utils::filter_flags::is_known_filter_expression(&raw_value) {
1402                filter = Some(raw_value);
1403            } else {
1404                aliases.push(raw_value);
1405                filter = Some(String::from("all"));
1406            }
1407        }
1408    }
1409
1410    if all || aliases.is_empty() {
1411        return commands::refresh_all(metrics, quiet, reindex, filter.as_ref(), no_filter).await;
1412    }
1413
1414    for alias in aliases {
1415        let metrics_clone = PerformanceMetrics {
1416            search_count: Arc::clone(&metrics.search_count),
1417            total_search_time: Arc::clone(&metrics.total_search_time),
1418            index_build_count: Arc::clone(&metrics.index_build_count),
1419            total_index_time: Arc::clone(&metrics.total_index_time),
1420            bytes_processed: Arc::clone(&metrics.bytes_processed),
1421            lines_searched: Arc::clone(&metrics.lines_searched),
1422        };
1423        commands::refresh_source(
1424            &alias,
1425            metrics_clone,
1426            quiet,
1427            reindex,
1428            filter.as_ref(),
1429            no_filter,
1430        )
1431        .await?;
1432    }
1433
1434    Ok(())
1435}
1436
1437fn print_diagnostics(
1438    cli: &Cli,
1439    metrics: &PerformanceMetrics,
1440    resource_monitor: Option<&mut ResourceMonitor>,
1441) {
1442    if cli.debug {
1443        metrics.print_summary();
1444    }
1445    if let Some(monitor) = resource_monitor {
1446        monitor.print_resource_usage();
1447    }
1448}
1449
1450fn apply_preference_defaults(cli: &mut Cli, prefs: &CliPreferences, args: &[String]) {
1451    if let Some(Commands::Search {
1452        show,
1453        score_precision,
1454        snippet_lines,
1455        ..
1456    }) = cli.command.as_mut()
1457    {
1458        let show_env = std::env::var("BLZ_SHOW").is_ok();
1459        if show.is_empty() && !flag_present(args, "--show") && !show_env {
1460            *show = prefs.default_show_components();
1461        }
1462
1463        if score_precision.is_none()
1464            && !flag_present(args, "--score-precision")
1465            && std::env::var("BLZ_SCORE_PRECISION").is_err()
1466        {
1467            *score_precision = Some(prefs.default_score_precision());
1468        }
1469
1470        if !flag_present(args, "--snippet-lines") && std::env::var("BLZ_SNIPPET_LINES").is_err() {
1471            *snippet_lines = prefs.default_snippet_lines();
1472        }
1473    }
1474}
1475
1476fn flag_present(args: &[String], flag: &str) -> bool {
1477    let flag_eq = flag;
1478    let flag_eq_with_equal = format!("{flag}=");
1479    args.iter()
1480        .any(|arg| arg == flag_eq || arg.starts_with(&flag_eq_with_equal))
1481}
1482
1483/// Pagination adjustments computed from history and continuation flags.
1484struct PaginationAdjustment {
1485    page: usize,
1486    limit: usize,
1487}
1488
1489/// Apply history-based pagination adjustments for --next/--previous flags.
1490///
1491/// Returns adjusted page and limit values based on history entry and continuation mode.
1492#[allow(clippy::too_many_arguments, clippy::fn_params_excessive_bools)]
1493fn apply_history_pagination(
1494    entry: &utils::preferences::SearchHistoryEntry,
1495    next: bool,
1496    previous: bool,
1497    provided_query: bool,
1498    all: bool,
1499    limit: Option<usize>,
1500    limit_was_explicit: bool,
1501    current_page: usize,
1502    current_limit: usize,
1503    all_results_limit: usize,
1504) -> Result<PaginationAdjustment> {
1505    let mut actual_page = current_page;
1506    let mut actual_limit = current_limit;
1507
1508    if next {
1509        validate_history_has_results(entry)?;
1510        validate_pagination_limit_consistency(
1511            "--next",
1512            all,
1513            limit_was_explicit,
1514            limit,
1515            entry.limit,
1516            all_results_limit,
1517        )?;
1518
1519        if let (Some(prev_page), Some(total_pages)) = (entry.page, entry.total_pages) {
1520            if prev_page >= total_pages {
1521                anyhow::bail!("Already at the last page (page {prev_page} of {total_pages})");
1522            }
1523            actual_page = prev_page + 1;
1524        } else {
1525            actual_page = entry.page.unwrap_or(1) + 1;
1526        }
1527
1528        if !limit_was_explicit {
1529            actual_limit = entry.limit.unwrap_or(actual_limit);
1530        }
1531    } else if previous {
1532        validate_history_has_results(entry)?;
1533        validate_pagination_limit_consistency(
1534            "--previous",
1535            all,
1536            limit_was_explicit,
1537            limit,
1538            entry.limit,
1539            all_results_limit,
1540        )?;
1541
1542        if let Some(prev_page) = entry.page {
1543            if prev_page <= 1 {
1544                anyhow::bail!("Already on first page");
1545            }
1546            actual_page = prev_page - 1;
1547        } else {
1548            anyhow::bail!("No previous page found in search history");
1549        }
1550
1551        if !limit_was_explicit {
1552            actual_limit = entry.limit.unwrap_or(actual_limit);
1553        }
1554    } else if !provided_query && !limit_was_explicit {
1555        actual_limit = entry.limit.unwrap_or(actual_limit);
1556    }
1557
1558    Ok(PaginationAdjustment {
1559        page: actual_page,
1560        limit: actual_limit,
1561    })
1562}
1563
1564/// Validate that history entry has non-zero results.
1565fn validate_history_has_results(entry: &utils::preferences::SearchHistoryEntry) -> Result<()> {
1566    if matches!(entry.total_pages, Some(0)) || matches!(entry.total_results, Some(0)) {
1567        anyhow::bail!(
1568            "Previous search returned 0 results. Rerun with a different query or source."
1569        );
1570    }
1571    Ok(())
1572}
1573
1574/// Resolve the search query from explicit input or history.
1575fn resolve_query(
1576    mut query: Option<String>,
1577    history: Option<&utils::preferences::SearchHistoryEntry>,
1578) -> Result<String> {
1579    if let Some(value) = query.take() {
1580        Ok(value)
1581    } else if let Some(entry) = history {
1582        Ok(entry.query.clone())
1583    } else {
1584        anyhow::bail!("No previous search found. Use 'blz search <query>' first.");
1585    }
1586}
1587
1588/// Resolve source filters from explicit input or history.
1589fn resolve_sources(
1590    sources: Vec<String>,
1591    history: Option<&utils::preferences::SearchHistoryEntry>,
1592) -> Vec<String> {
1593    if !sources.is_empty() {
1594        sources
1595    } else if let Some(entry) = history {
1596        entry.source.as_ref().map_or_else(Vec::new, |source_str| {
1597            source_str
1598                .split(',')
1599                .map(|s| s.trim().to_string())
1600                .collect()
1601        })
1602    } else {
1603        Vec::new()
1604    }
1605}
1606
1607/// Validate that pagination limit hasn't changed when using continuation flags.
1608fn validate_pagination_limit_consistency(
1609    flag: &str,
1610    all: bool,
1611    limit_was_explicit: bool,
1612    limit: Option<usize>,
1613    history_limit: Option<usize>,
1614    all_results_limit: usize,
1615) -> Result<()> {
1616    let history_all = history_limit.is_some_and(|value| value >= all_results_limit);
1617    if all != history_all {
1618        anyhow::bail!(
1619            "Cannot use {flag} when changing page size or --all; rerun without {flag} or reuse the previous pagination flags."
1620        );
1621    }
1622    if limit_was_explicit {
1623        if let Some(requested_limit) = limit {
1624            if history_limit != Some(requested_limit) {
1625                anyhow::bail!(
1626                    "Cannot use {flag} when changing page size; rerun without {flag} or reuse the previous limit."
1627                );
1628            }
1629        }
1630    }
1631    Ok(())
1632}
1633
1634/// Validate that a continuation flag (--next/--previous) is not combined with incompatible options.
1635fn validate_continuation_flag(
1636    flag: &str,
1637    provided_query: bool,
1638    sources: &[String],
1639    page: usize,
1640    last: bool,
1641) -> Result<()> {
1642    if provided_query {
1643        anyhow::bail!(
1644            "Cannot combine {flag} with an explicit query. Remove the query to continue from the previous search."
1645        );
1646    }
1647    if !sources.is_empty() {
1648        anyhow::bail!(
1649            "Cannot combine {flag} with --source. Omit --source to reuse the last search context."
1650        );
1651    }
1652    if page != 1 {
1653        anyhow::bail!("Cannot combine {flag} with --page. Use one pagination option at a time.");
1654    }
1655    if last {
1656        anyhow::bail!("Cannot combine {flag} with --last. Choose a single continuation flag.");
1657    }
1658    Ok(())
1659}
1660
1661#[cfg(test)]
1662#[allow(
1663    clippy::unwrap_used,
1664    clippy::panic,
1665    clippy::disallowed_macros,
1666    clippy::needless_collect,
1667    clippy::unnecessary_wraps,
1668    clippy::deref_addrof
1669)]
1670mod tests {
1671    use super::*;
1672    use crate::utils::constants::RESERVED_KEYWORDS;
1673    use crate::utils::parsing::{LineRange, parse_line_ranges};
1674    use crate::utils::validation::validate_alias;
1675    use std::collections::HashSet;
1676
1677    #[test]
1678    fn test_reserved_keywords_validation() {
1679        for &keyword in RESERVED_KEYWORDS {
1680            let result = validate_alias(keyword);
1681            assert!(
1682                result.is_err(),
1683                "Reserved keyword '{keyword}' should be rejected"
1684            );
1685
1686            let error_msg = result.unwrap_err().to_string();
1687            assert!(
1688                error_msg.contains(keyword),
1689                "Error message should contain the reserved keyword '{keyword}'"
1690            );
1691        }
1692    }
1693
1694    #[test]
1695    fn test_valid_aliases_allowed() {
1696        let valid_aliases = ["react", "nextjs", "python", "rust", "docs", "api", "guide"];
1697
1698        for &alias in &valid_aliases {
1699            let result = validate_alias(alias);
1700            assert!(result.is_ok(), "Valid alias '{alias}' should be accepted");
1701        }
1702    }
1703
1704    #[test]
1705    fn test_language_names_are_not_reserved() {
1706        let language_names = [
1707            "node",
1708            "python",
1709            "rust",
1710            "go",
1711            "java",
1712            "javascript",
1713            "typescript",
1714        ];
1715
1716        for &lang in &language_names {
1717            assert!(
1718                !RESERVED_KEYWORDS.contains(&lang),
1719                "Language name '{lang}' should not be reserved"
1720            );
1721
1722            let result = validate_alias(lang);
1723            assert!(
1724                result.is_ok(),
1725                "Language name '{lang}' should be usable as alias"
1726            );
1727        }
1728    }
1729
1730    #[test]
1731    fn test_reserved_keywords_case_insensitive() {
1732        let result = validate_alias("ADD");
1733        assert!(
1734            result.is_err(),
1735            "Reserved keyword 'ADD' (uppercase) should be rejected"
1736        );
1737
1738        let result = validate_alias("Add");
1739        assert!(
1740            result.is_err(),
1741            "Reserved keyword 'Add' (mixed case) should be rejected"
1742        );
1743    }
1744
1745    #[test]
1746    fn test_line_range_parsing() {
1747        // Single line
1748        let ranges = parse_line_ranges("42").expect("Should parse single line");
1749        assert_eq!(ranges.len(), 1);
1750        assert!(matches!(ranges[0], LineRange::Single(42)));
1751
1752        // Colon range
1753        let ranges = parse_line_ranges("120:142").expect("Should parse colon range");
1754        assert_eq!(ranges.len(), 1);
1755        assert!(matches!(ranges[0], LineRange::Range(120, 142)));
1756
1757        // Dash range
1758        let ranges = parse_line_ranges("120-142").expect("Should parse dash range");
1759        assert_eq!(ranges.len(), 1);
1760        assert!(matches!(ranges[0], LineRange::Range(120, 142)));
1761
1762        // Plus syntax
1763        let ranges = parse_line_ranges("36+20").expect("Should parse plus syntax");
1764        assert_eq!(ranges.len(), 1);
1765        assert!(matches!(ranges[0], LineRange::PlusCount(36, 20)));
1766
1767        // Multiple ranges
1768        let ranges =
1769            parse_line_ranges("36:43,120-142,200+10").expect("Should parse multiple ranges");
1770        assert_eq!(ranges.len(), 3);
1771    }
1772
1773    #[test]
1774    fn test_line_range_parsing_errors() {
1775        assert!(parse_line_ranges("0").is_err(), "Line 0 should be invalid");
1776        assert!(
1777            parse_line_ranges("50:30").is_err(),
1778            "Backwards range should be invalid"
1779        );
1780        assert!(
1781            parse_line_ranges("50+0").is_err(),
1782            "Plus zero count should be invalid"
1783        );
1784        assert!(
1785            parse_line_ranges("abc").is_err(),
1786            "Invalid format should be rejected"
1787        );
1788    }
1789
1790    #[test]
1791    fn test_reserved_keywords_no_duplicates() {
1792        let mut seen = HashSet::new();
1793        for &keyword in RESERVED_KEYWORDS {
1794            assert!(
1795                seen.insert(keyword),
1796                "Reserved keyword '{keyword}' appears multiple times"
1797            );
1798        }
1799    }
1800
1801    // CLI flag combination and validation tests
1802
1803    #[test]
1804    fn test_cli_flag_combinations() {
1805        use clap::Parser;
1806
1807        // Test valid flag combinations
1808        let valid_combinations = vec![
1809            vec!["blz", "search", "rust", "--limit", "20"],
1810            vec!["blz", "search", "rust", "--alias", "node", "--limit", "10"],
1811            vec!["blz", "search", "rust", "--all"],
1812            vec!["blz", "add", "test", "https://example.com/llms.txt"],
1813            vec!["blz", "list", "--output", "json"],
1814            vec!["blz", "update", "--all"],
1815            vec!["blz", "remove", "test"],
1816            vec!["blz", "get", "test", "--lines", "1-10"],
1817            vec!["blz", "lookup", "react"],
1818        ];
1819
1820        for combination in valid_combinations {
1821            let result = Cli::try_parse_from(combination.clone());
1822            assert!(
1823                result.is_ok(),
1824                "Valid combination should parse: {combination:?}"
1825            );
1826        }
1827    }
1828
1829    #[test]
1830    #[allow(deprecated)]
1831    fn test_cli_parse_refresh_multiple_aliases() {
1832        use clap::Parser;
1833
1834        let cli = Cli::try_parse_from(vec!["blz", "refresh", "bun", "react"]).unwrap();
1835        match cli.command {
1836            Some(Commands::Refresh { aliases, all, .. }) => {
1837                assert_eq!(aliases, vec!["bun", "react"]);
1838                assert!(!all);
1839            },
1840            other => panic!("Expected refresh command, got {other:?}"),
1841        }
1842    }
1843
1844    #[test]
1845    fn test_cli_refresh_all_conflict_with_aliases() {
1846        use clap::Parser;
1847
1848        let result = Cli::try_parse_from(vec!["blz", "refresh", "bun", "--all"]);
1849        assert!(
1850            result.is_err(),
1851            "Should error when both --all and aliases are provided"
1852        );
1853    }
1854
1855    #[test]
1856    #[allow(deprecated)]
1857    fn test_cli_parse_update_multiple_aliases() {
1858        use clap::Parser;
1859
1860        // Test deprecated 'update' command for backward compatibility
1861        let cli = Cli::try_parse_from(vec!["blz", "update", "bun", "react"]).unwrap();
1862        match cli.command {
1863            Some(Commands::Update { aliases, all, .. }) => {
1864                assert_eq!(aliases, vec!["bun", "react"]);
1865                assert!(!all);
1866            },
1867            other => panic!("Expected update command, got {other:?}"),
1868        }
1869    }
1870
1871    #[test]
1872    #[allow(deprecated)]
1873    fn test_cli_update_all_conflict_with_aliases() {
1874        use clap::Parser;
1875
1876        // Test deprecated 'update' command for backward compatibility
1877        let result = Cli::try_parse_from(vec!["blz", "update", "bun", "--all"]);
1878        assert!(
1879            result.is_err(),
1880            "--all should conflict with explicit aliases"
1881        );
1882    }
1883
1884    #[test]
1885    fn test_cli_invalid_flag_combinations() {
1886        use clap::Parser;
1887
1888        // Test invalid flag combinations that should fail
1889        let invalid_combinations = vec![
1890            // Missing required arguments
1891            vec!["blz", "add", "alias"], // Missing URL
1892            // Note: "blz get alias" is now valid (supports colon syntax like "alias:1-3")
1893            vec!["blz", "search"], // Missing query
1894            vec!["blz", "lookup"], // Missing query
1895            // Invalid flag values
1896            vec!["blz", "search", "rust", "--limit", "-5"], // Negative limit
1897            vec!["blz", "search", "rust", "--page", "-1"],  // Negative page
1898            vec!["blz", "list", "--output", "invalid"],     // Invalid output format
1899
1900                                                            // Note: --all with --limit is actually valid (--all sets limit to 10000)
1901                                                            // Note: update with alias and --all is also valid
1902
1903                                                            // Add actual invalid combinations here as needed
1904        ];
1905
1906        for combination in invalid_combinations {
1907            let result = Cli::try_parse_from(combination.clone());
1908            assert!(
1909                result.is_err(),
1910                "Invalid combination should fail: {combination:?}"
1911            );
1912        }
1913    }
1914
1915    #[test]
1916    fn test_cli_help_generation() {
1917        use clap::Parser;
1918
1919        // Test that help can be generated without errors
1920        let help_commands = vec![
1921            vec!["blz", "--help"],
1922            vec!["blz", "search", "--help"],
1923            vec!["blz", "add", "--help"],
1924            vec!["blz", "list", "--help"],
1925            vec!["blz", "get", "--help"],
1926            vec!["blz", "update", "--help"],
1927            vec!["blz", "remove", "--help"],
1928            vec!["blz", "lookup", "--help"],
1929            vec!["blz", "completions", "--help"],
1930        ];
1931
1932        for help_cmd in help_commands {
1933            let result = Cli::try_parse_from(help_cmd.clone());
1934            // Help commands should fail parsing but with a specific help error
1935            if let Err(error) = result {
1936                assert!(
1937                    error.kind() == clap::error::ErrorKind::DisplayHelp,
1938                    "Help command should display help: {help_cmd:?}"
1939                );
1940            } else {
1941                panic!("Help command should not succeed: {help_cmd:?}");
1942            }
1943        }
1944    }
1945
1946    #[test]
1947    fn test_cli_version_flag() {
1948        use clap::Parser;
1949
1950        let version_commands = vec![vec!["blz", "--version"], vec!["blz", "-V"]];
1951
1952        for version_cmd in version_commands {
1953            let result = Cli::try_parse_from(version_cmd.clone());
1954            // Version commands should fail parsing but with a specific version error
1955            if let Err(error) = result {
1956                assert!(
1957                    error.kind() == clap::error::ErrorKind::DisplayVersion,
1958                    "Version command should display version: {version_cmd:?}"
1959                );
1960            } else {
1961                panic!("Version command should not succeed: {version_cmd:?}");
1962            }
1963        }
1964    }
1965
1966    #[test]
1967    fn test_cli_default_values() {
1968        use clap::Parser;
1969
1970        // Test that default values are set correctly
1971        let cli = Cli::try_parse_from(vec!["blz", "search", "test"]).unwrap();
1972
1973        if let Some(Commands::Search {
1974            limit,
1975            page,
1976            all,
1977            format,
1978            ..
1979        }) = cli.command
1980        {
1981            assert_eq!(
1982                limit, None,
1983                "Default limit should be unset (defaults to 50)"
1984            );
1985            assert_eq!(page, 1, "Default page should be 1");
1986            assert!(!all, "Default all should be false");
1987            // When running tests, stdout is not a terminal, so default is JSON when piped
1988            let expected_format = if is_terminal::IsTerminal::is_terminal(&std::io::stdout()) {
1989                crate::output::OutputFormat::Text
1990            } else {
1991                crate::output::OutputFormat::Json
1992            };
1993            assert_eq!(
1994                format.resolve(false),
1995                expected_format,
1996                "Default format should be JSON when piped, Text when terminal"
1997            );
1998        } else {
1999            panic!("Expected search command");
2000        }
2001    }
2002
2003    #[test]
2004    fn test_cli_flag_validation_edge_cases() {
2005        use clap::Parser;
2006
2007        // Test edge cases for numeric values
2008        let edge_cases = vec![
2009            // Very large values
2010            vec!["blz", "search", "rust", "--limit", "999999"],
2011            vec!["blz", "search", "rust", "--page", "999999"],
2012            // Boundary values
2013            vec!["blz", "search", "rust", "--limit", "1"],
2014            vec!["blz", "search", "rust", "--page", "1"],
2015            // Maximum reasonable values
2016            vec!["blz", "search", "rust", "--limit", "10000"],
2017            vec!["blz", "search", "rust", "--page", "1000"],
2018        ];
2019
2020        for edge_case in edge_cases {
2021            let result = Cli::try_parse_from(edge_case.clone());
2022
2023            // All these should parse successfully (validation happens at runtime)
2024            assert!(result.is_ok(), "Edge case should parse: {edge_case:?}");
2025        }
2026    }
2027
2028    #[test]
2029    fn test_cli_string_argument_validation() {
2030        use clap::Parser;
2031
2032        // Test various string inputs
2033        let string_cases = vec![
2034            // Normal cases
2035            vec!["blz", "search", "normal query"],
2036            vec!["blz", "add", "test-alias", "https://example.com/llms.txt"],
2037            vec!["blz", "lookup", "react"],
2038            // Edge cases
2039            vec!["blz", "search", ""], // Empty query (should be handled at runtime)
2040            vec![
2041                "blz",
2042                "search",
2043                "very-long-query-with-lots-of-words-to-test-limits",
2044            ],
2045            vec!["blz", "add", "alias", "file:///local/path.txt"], // File URL
2046            // Special characters
2047            vec!["blz", "search", "query with spaces"],
2048            vec!["blz", "search", "query-with-dashes"],
2049            vec!["blz", "search", "query_with_underscores"],
2050            vec!["blz", "add", "test", "https://example.com/path?query=value"],
2051        ];
2052
2053        for string_case in string_cases {
2054            let result = Cli::try_parse_from(string_case.clone());
2055
2056            // Most string cases should parse (validation happens at runtime)
2057            assert!(result.is_ok(), "String case should parse: {string_case:?}");
2058        }
2059    }
2060
2061    #[test]
2062    fn test_cli_output_format_validation() {
2063        use clap::Parser;
2064
2065        // Test all valid output formats
2066        let format_options = vec![
2067            ("text", crate::output::OutputFormat::Text),
2068            ("json", crate::output::OutputFormat::Json),
2069            ("jsonl", crate::output::OutputFormat::Jsonl),
2070        ];
2071
2072        for (format_str, expected_format) in &format_options {
2073            let cli = Cli::try_parse_from(vec!["blz", "list", "--format", *format_str]).unwrap();
2074
2075            if let Some(Commands::List { format, .. }) = cli.command {
2076                assert_eq!(
2077                    format.resolve(false),
2078                    *expected_format,
2079                    "Format should match: {format_str}"
2080                );
2081            } else {
2082                panic!("Expected list command");
2083            }
2084        }
2085
2086        // Alias --output should continue to work for compatibility
2087        for (format_str, expected_format) in &format_options {
2088            let cli = Cli::try_parse_from(vec!["blz", "list", "--output", *format_str]).unwrap();
2089
2090            if let Some(Commands::List { format, .. }) = cli.command {
2091                assert_eq!(
2092                    format.resolve(false),
2093                    *expected_format,
2094                    "Alias --output should map to {format_str}"
2095                );
2096            } else {
2097                panic!("Expected list command");
2098            }
2099        }
2100
2101        // Test invalid format value
2102        let result = Cli::try_parse_from(vec!["blz", "list", "--format", "invalid"]);
2103        assert!(result.is_err(), "Invalid output format should fail");
2104    }
2105
2106    fn to_string_vec(items: &[&str]) -> Vec<String> {
2107        items.iter().copied().map(str::to_owned).collect()
2108    }
2109
2110    #[test]
2111    #[allow(deprecated)]
2112    fn anchors_alias_still_parses_to_toc() {
2113        use clap::Parser;
2114
2115        let raw = to_string_vec(&["blz", "anchors", "react"]);
2116        let cli = Cli::try_parse_from(raw).expect("anchors alias should parse");
2117        match cli.command {
2118            Some(Commands::Toc { alias, .. }) => assert_eq!(alias, Some("react".to_string())),
2119            other => panic!("expected toc command, got {other:?}"),
2120        }
2121    }
2122
2123    #[test]
2124    fn test_cli_boolean_flags() {
2125        use clap::Parser;
2126
2127        // Test boolean flags
2128        let bool_flag_cases = vec![
2129            // Verbose flag
2130            (
2131                vec!["blz", "--verbose", "search", "test"],
2132                true,
2133                false,
2134                false,
2135            ),
2136            (vec!["blz", "-v", "search", "test"], true, false, false),
2137            // Debug flag
2138            (vec!["blz", "--debug", "search", "test"], false, true, false),
2139            // Profile flag
2140            (
2141                vec!["blz", "--profile", "search", "test"],
2142                false,
2143                false,
2144                true,
2145            ),
2146            // Multiple flags
2147            (
2148                vec!["blz", "--verbose", "--debug", "--profile", "search", "test"],
2149                true,
2150                true,
2151                true,
2152            ),
2153            (
2154                vec!["blz", "-v", "--debug", "--profile", "search", "test"],
2155                true,
2156                true,
2157                true,
2158            ),
2159        ];
2160
2161        for (args, expected_verbose, expected_debug, expected_profile) in bool_flag_cases {
2162            let cli = Cli::try_parse_from(args.clone()).unwrap();
2163
2164            assert_eq!(
2165                cli.verbose, expected_verbose,
2166                "Verbose flag mismatch for: {args:?}"
2167            );
2168            assert_eq!(
2169                cli.debug, expected_debug,
2170                "Debug flag mismatch for: {args:?}"
2171            );
2172            assert_eq!(
2173                cli.profile, expected_profile,
2174                "Profile flag mismatch for: {args:?}"
2175            );
2176        }
2177    }
2178
2179    #[test]
2180    fn test_cli_subcommand_specific_flags() {
2181        use clap::Parser;
2182
2183        // Test search-specific flags
2184        let cli = Cli::try_parse_from(vec![
2185            "blz", "search", "rust", "--alias", "node", "--limit", "20", "--page", "2", "--top",
2186            "10", "--format", "json",
2187        ])
2188        .unwrap();
2189
2190        if let Some(Commands::Search {
2191            sources,
2192            limit,
2193            page,
2194            top,
2195            format,
2196            ..
2197        }) = cli.command
2198        {
2199            assert_eq!(sources, vec!["node"]);
2200            assert_eq!(limit, Some(20));
2201            assert_eq!(page, 2);
2202            assert!(top.is_some());
2203            assert_eq!(format.resolve(false), crate::output::OutputFormat::Json);
2204        } else {
2205            panic!("Expected search command");
2206        }
2207
2208        // Test add-specific flags
2209        let cli = Cli::try_parse_from(vec![
2210            "blz",
2211            "add",
2212            "test",
2213            "https://example.com/llms.txt",
2214            "--yes",
2215        ])
2216        .unwrap();
2217
2218        if let Some(Commands::Add(args)) = cli.command {
2219            assert_eq!(args.alias.as_deref(), Some("test"));
2220            assert_eq!(args.url.as_deref(), Some("https://example.com/llms.txt"));
2221            assert!(args.aliases.is_empty());
2222            assert!(args.tags.is_empty());
2223            assert!(args.name.is_none());
2224            assert!(args.description.is_none());
2225            assert!(args.category.is_none());
2226            assert!(args.yes);
2227            assert!(!args.dry_run);
2228            assert!(args.manifest.is_none());
2229        } else {
2230            panic!("Expected add command");
2231        }
2232
2233        // Test get-specific flags
2234        let cli = Cli::try_parse_from(vec![
2235            "blz",
2236            "get",
2237            "test",
2238            "--lines",
2239            "1-10",
2240            "--context",
2241            "5",
2242        ])
2243        .unwrap();
2244
2245        if let Some(Commands::Get {
2246            targets,
2247            lines,
2248            source,
2249            context,
2250            block,
2251            max_lines,
2252            format,
2253            copy: _,
2254            ..
2255        }) = cli.command
2256        {
2257            assert_eq!(targets, vec!["test".to_string()]);
2258            assert_eq!(lines, Some("1-10".to_string()));
2259            assert!(source.is_none());
2260            assert_eq!(context, Some(crate::cli::ContextMode::Symmetric(5)));
2261            assert!(!block);
2262            assert_eq!(max_lines, None);
2263            let _ = format; // ignore
2264        } else {
2265            panic!("Expected get command");
2266        }
2267    }
2268
2269    #[test]
2270    fn test_cli_special_argument_parsing() {
2271        use clap::Parser;
2272
2273        // Test line range parsing edge cases
2274        let line_range_cases = vec![
2275            "1",
2276            "1-10",
2277            "1:10",
2278            "1+5",
2279            "10,20,30",
2280            "1-5,10-15,20+5",
2281            "100:200",
2282        ];
2283
2284        for line_range in line_range_cases {
2285            let result = Cli::try_parse_from(vec!["blz", "get", "test", "--lines", line_range]);
2286            assert!(result.is_ok(), "Line range should parse: {line_range}");
2287        }
2288
2289        // Test URL parsing for add command
2290        let url_cases = vec![
2291            "https://example.com/llms.txt",
2292            "http://localhost:3000/llms.txt",
2293            "https://api.example.com/v1/docs/llms.txt",
2294            "https://example.com/llms.txt?version=1",
2295            "https://raw.githubusercontent.com/user/repo/main/llms.txt",
2296        ];
2297
2298        for url in url_cases {
2299            let result = Cli::try_parse_from(vec!["blz", "add", "test", url]);
2300            assert!(result.is_ok(), "URL should parse: {url}");
2301        }
2302    }
2303
2304    #[test]
2305    fn test_cli_error_messages() {
2306        use clap::Parser;
2307
2308        // Test that error messages are informative
2309        let error_cases = vec![
2310            // Missing required arguments
2311            (vec!["blz", "add"], "missing"),
2312            (vec!["blz", "search"], "required"),
2313            // Note: "blz get alias" is now valid (supports colon syntax like "alias:1-3")
2314            // Invalid values
2315            (vec!["blz", "list", "--format", "invalid"], "invalid"),
2316        ];
2317
2318        for (args, expected_error_content) in error_cases {
2319            let result = Cli::try_parse_from(args.clone());
2320
2321            assert!(result.is_err(), "Should error for: {args:?}");
2322
2323            let error_msg = format!("{:?}", result.unwrap_err()).to_lowercase();
2324            assert!(
2325                error_msg.contains(expected_error_content),
2326                "Error message should contain '{expected_error_content}' for args {args:?}, got: {error_msg}"
2327            );
2328        }
2329    }
2330
2331    #[test]
2332    fn test_cli_argument_order_independence() {
2333        use clap::Parser;
2334
2335        // Test that global flags can appear in different positions
2336        let equivalent_commands = vec![
2337            vec![
2338                vec!["blz", "--verbose", "search", "rust"],
2339                vec!["blz", "search", "--verbose", "rust"],
2340            ],
2341            vec![
2342                vec!["blz", "--debug", "--profile", "search", "rust"],
2343                vec!["blz", "search", "rust", "--debug", "--profile"],
2344                vec!["blz", "--debug", "search", "--profile", "rust"],
2345            ],
2346        ];
2347
2348        for command_group in equivalent_commands {
2349            let mut parsed_commands = Vec::new();
2350
2351            for args in &command_group {
2352                let result = Cli::try_parse_from(args.clone());
2353                assert!(result.is_ok(), "Should parse: {args:?}");
2354                parsed_commands.push(result.unwrap());
2355            }
2356
2357            // All commands in the group should parse to equivalent structures
2358            let first = &parsed_commands[0];
2359            for other in &parsed_commands[1..] {
2360                assert_eq!(first.verbose, other.verbose, "Verbose flags should match");
2361                assert_eq!(first.debug, other.debug, "Debug flags should match");
2362                assert_eq!(first.profile, other.profile, "Profile flags should match");
2363            }
2364        }
2365    }
2366
2367    // Shell completion generation and accuracy tests
2368
2369    #[test]
2370    fn test_completion_generation_for_all_shells() {
2371        use clap_complete::Shell;
2372
2373        // Test that completions can be generated for all supported shells without panicking
2374        let shells = vec![
2375            Shell::Bash,
2376            Shell::Zsh,
2377            Shell::Fish,
2378            Shell::PowerShell,
2379            Shell::Elvish,
2380        ];
2381
2382        for shell in shells {
2383            // Should not panic - this is the main test
2384            let result = std::panic::catch_unwind(|| {
2385                crate::commands::generate(shell);
2386            });
2387
2388            assert!(
2389                result.is_ok(),
2390                "Completion generation should not panic for {shell:?}"
2391            );
2392        }
2393    }
2394
2395    #[test]
2396    fn test_completion_cli_structure_contains_all_subcommands() {
2397        use crate::cli::Cli;
2398        use clap::CommandFactory;
2399
2400        // Test that our CLI structure has all expected subcommands (which completions will include)
2401        let cmd = Cli::command();
2402
2403        let subcommands: Vec<&str> = cmd.get_subcommands().map(clap::Command::get_name).collect();
2404
2405        // Verify all main subcommands are present in CLI structure
2406        let expected_commands = vec![
2407            "search",
2408            "add",
2409            "list",
2410            "get",
2411            "refresh",
2412            "update",
2413            "remove",
2414            "lookup",
2415            "diff",
2416            "completions",
2417        ];
2418
2419        for expected_command in expected_commands {
2420            assert!(
2421                subcommands.contains(&expected_command),
2422                "CLI should have '{expected_command}' subcommand for completions"
2423            );
2424        }
2425
2426        // Verify command aliases are configured in CLI structure
2427        let list_cmd = cmd
2428            .get_subcommands()
2429            .find(|sub| sub.get_name() == "list")
2430            .expect("Should have list command");
2431
2432        let aliases: Vec<&str> = list_cmd.get_all_aliases().collect();
2433        assert!(
2434            aliases.contains(&"sources"),
2435            "List command should have 'sources' alias"
2436        );
2437
2438        // `rm` is now its own command (not an alias of `remove`)
2439        let rm_cmd = cmd
2440            .get_subcommands()
2441            .find(|sub| sub.get_name() == "rm")
2442            .expect("Should have rm command");
2443        assert_eq!(rm_cmd.get_name(), "rm");
2444
2445        // `remove` is deprecated but still exists
2446        let remove_cmd = cmd.get_subcommands().find(|sub| sub.get_name() == "remove");
2447        assert!(
2448            remove_cmd.is_some(),
2449            "Remove command should still exist (deprecated)"
2450        );
2451    }
2452
2453    #[test]
2454    fn test_completion_cli_structure_contains_global_flags() {
2455        use crate::cli::Cli;
2456        use clap::CommandFactory;
2457
2458        // Test that our CLI structure has all expected global flags (which completions will include)
2459        let cmd = Cli::command();
2460
2461        let global_args: Vec<&str> = cmd
2462            .get_arguments()
2463            .filter(|arg| arg.is_global_set())
2464            .map(|arg| arg.get_id().as_str())
2465            .collect();
2466
2467        // Verify global flags are present in CLI structure
2468        let expected_global_flags = vec!["verbose", "debug", "profile"];
2469
2470        for expected_flag in expected_global_flags {
2471            assert!(
2472                global_args.contains(&expected_flag),
2473                "CLI should have global flag '{expected_flag}' for completions"
2474            );
2475        }
2476
2477        // Verify verbose flag properties
2478        let verbose_arg = cmd
2479            .get_arguments()
2480            .find(|arg| arg.get_id().as_str() == "verbose")
2481            .expect("Should have verbose argument");
2482
2483        assert!(
2484            verbose_arg.get_long().is_some(),
2485            "Verbose should have long form --verbose"
2486        );
2487        assert_eq!(
2488            verbose_arg.get_long(),
2489            Some("verbose"),
2490            "Verbose long form should be --verbose"
2491        );
2492        assert!(verbose_arg.is_global_set(), "Verbose should be global");
2493    }
2494
2495    #[test]
2496    fn test_completion_cli_structure_contains_subcommand_flags() {
2497        use crate::cli::Cli;
2498        use clap::CommandFactory;
2499
2500        let cmd = Cli::command();
2501
2502        // Check search command flags
2503        let search_cmd = cmd
2504            .get_subcommands()
2505            .find(|sub| sub.get_name() == "search")
2506            .expect("Should have search command");
2507
2508        let search_args: Vec<&str> = search_cmd
2509            .get_arguments()
2510            .map(|arg| arg.get_id().as_str())
2511            .collect();
2512
2513        let expected_search_flags = vec![
2514            "sources",
2515            "limit",
2516            "all",
2517            "page",
2518            "top",
2519            "format",
2520            "show",
2521            "no_summary",
2522        ];
2523        for expected_flag in expected_search_flags {
2524            assert!(
2525                search_args.contains(&expected_flag),
2526                "Search command should have '{expected_flag}' flag for completions"
2527            );
2528        }
2529
2530        // Check add command flags
2531        let add_cmd = cmd
2532            .get_subcommands()
2533            .find(|sub| sub.get_name() == "add")
2534            .expect("Should have add command");
2535
2536        let add_args: Vec<&str> = add_cmd
2537            .get_arguments()
2538            .map(|arg| arg.get_id().as_str())
2539            .collect();
2540
2541        assert!(
2542            add_args.contains(&"yes"),
2543            "Add command should have 'yes' flag"
2544        );
2545
2546        // Check get command flags
2547        let get_cmd = cmd
2548            .get_subcommands()
2549            .find(|sub| sub.get_name() == "get")
2550            .expect("Should have get command");
2551
2552        let get_args: Vec<&str> = get_cmd
2553            .get_arguments()
2554            .map(|arg| arg.get_id().as_str())
2555            .collect();
2556
2557        assert!(
2558            get_args.contains(&"lines"),
2559            "Get command should have 'lines' flag"
2560        );
2561        assert!(
2562            get_args.contains(&"context"),
2563            "Get command should have 'context' flag"
2564        );
2565
2566        // Check that output argument has value_enum (which provides completion values)
2567        let format_arg = search_cmd
2568            .get_arguments()
2569            .find(|arg| arg.get_id().as_str() == "format")
2570            .expect("Search should have format argument");
2571
2572        assert!(
2573            !format_arg.get_possible_values().is_empty(),
2574            "Format argument should have possible values for completion"
2575        );
2576    }
2577
2578    #[test]
2579    fn test_completion_generation_consistency() {
2580        use clap_complete::Shell;
2581
2582        // Generate completions multiple times to ensure consistency (no panics)
2583        let shells_to_test = vec![Shell::Bash, Shell::Zsh, Shell::Fish];
2584
2585        for shell in shells_to_test {
2586            // Should not panic on multiple generations
2587            for _ in 0..3 {
2588                let result = std::panic::catch_unwind(|| {
2589                    crate::commands::generate(shell);
2590                });
2591                assert!(
2592                    result.is_ok(),
2593                    "Completion generation should be consistent for {shell:?}"
2594                );
2595            }
2596        }
2597    }
2598
2599    #[test]
2600    fn test_completion_command_parsing() {
2601        use clap::Parser;
2602
2603        // Test that the completions command parses correctly for all shells
2604        let shell_completions = vec![
2605            vec!["blz", "completions", "bash"],
2606            vec!["blz", "completions", "zsh"],
2607            vec!["blz", "completions", "fish"],
2608            vec!["blz", "completions", "powershell"],
2609            vec!["blz", "completions", "elvish"],
2610        ];
2611
2612        for args in shell_completions {
2613            let result = Cli::try_parse_from(args.clone());
2614            assert!(result.is_ok(), "Completions command should parse: {args:?}");
2615
2616            if let Ok(cli) = result {
2617                match cli.command {
2618                    Some(Commands::Completions { shell: _, .. }) => {
2619                        // Expected - completions command parsed successfully
2620                    },
2621                    other => {
2622                        panic!("Expected Completions command, got: {other:?} for args: {args:?}");
2623                    },
2624                }
2625            }
2626        }
2627    }
2628
2629    #[test]
2630    fn test_completion_invalid_shell_handling() {
2631        use clap::Parser;
2632
2633        // Test that invalid shell names are rejected
2634        let invalid_shells = vec![
2635            vec!["blz", "completions", "invalid"],
2636            vec!["blz", "completions", "cmd"],
2637            vec!["blz", "completions", ""],
2638            vec!["blz", "completions", "bash_typo"],
2639            vec!["blz", "completions", "ZSH"], // Wrong case
2640        ];
2641
2642        for args in invalid_shells {
2643            let result = Cli::try_parse_from(args.clone());
2644            assert!(
2645                result.is_err(),
2646                "Invalid shell should be rejected: {args:?}"
2647            );
2648        }
2649    }
2650
2651    #[test]
2652    fn test_completion_help_generation() {
2653        use clap::Parser;
2654
2655        // Test that help for completions command works
2656        let help_commands = vec![
2657            vec!["blz", "completions", "--help"],
2658            vec!["blz", "completions", "-h"],
2659        ];
2660
2661        for help_cmd in help_commands {
2662            let result = Cli::try_parse_from(help_cmd.clone());
2663
2664            if let Err(error) = result {
2665                assert_eq!(
2666                    error.kind(),
2667                    clap::error::ErrorKind::DisplayHelp,
2668                    "Completions help should display help: {help_cmd:?}"
2669                );
2670
2671                let help_text = error.to_string();
2672                assert!(
2673                    help_text.contains("completions"),
2674                    "Help text should mention completions"
2675                );
2676                assert!(
2677                    help_text.contains("shell") || help_text.contains("Shell"),
2678                    "Help text should mention shell parameter"
2679                );
2680            } else {
2681                panic!("Help command should not succeed: {help_cmd:?}");
2682            }
2683        }
2684    }
2685
2686    #[test]
2687    fn test_completion_integration_with_clap() {
2688        use crate::cli::Cli;
2689        use clap::CommandFactory;
2690
2691        // Test that our CLI structure is compatible with clap_complete
2692        let cmd = Cli::command();
2693
2694        // Verify basic command structure that completion depends on
2695        assert_eq!(cmd.get_name(), "blz", "Command name should be 'blz'");
2696
2697        // Verify subcommands are properly configured
2698        let subcommands: Vec<&str> = cmd.get_subcommands().map(clap::Command::get_name).collect();
2699
2700        let expected_subcommands = vec![
2701            "completions",
2702            "add",
2703            "lookup",
2704            "search",
2705            "get",
2706            "list",
2707            "refresh",
2708            "update",
2709            "remove",
2710            "diff",
2711        ];
2712
2713        for expected in expected_subcommands {
2714            assert!(
2715                subcommands.contains(&expected),
2716                "Command should have subcommand '{expected}', found: {subcommands:?}"
2717            );
2718        }
2719
2720        // Verify completions subcommand has proper shell argument
2721        let completions_cmd = cmd
2722            .get_subcommands()
2723            .find(|sub| sub.get_name() == "completions")
2724            .expect("Should have completions subcommand");
2725
2726        let shell_arg = completions_cmd
2727            .get_arguments()
2728            .find(|arg| arg.get_id() == "shell")
2729            .expect("Completions should have shell argument");
2730
2731        assert!(
2732            shell_arg.is_positional(),
2733            "Shell argument should be positional"
2734        );
2735    }
2736
2737    #[test]
2738    fn test_multi_source_search_parsing() {
2739        use clap::Parser;
2740
2741        // Test comma-separated sources
2742        let cli = Cli::try_parse_from(vec![
2743            "blz",
2744            "search",
2745            "hooks",
2746            "--source",
2747            "react,vue,svelte",
2748        ])
2749        .unwrap();
2750
2751        if let Some(Commands::Search { sources, .. }) = cli.command {
2752            assert_eq!(sources, vec!["react", "vue", "svelte"]);
2753        } else {
2754            panic!("Expected search command");
2755        }
2756    }
2757
2758    #[test]
2759    fn test_single_source_search_parsing() {
2760        use clap::Parser;
2761
2762        // Test single source (backward compatibility)
2763        let cli = Cli::try_parse_from(vec!["blz", "search", "hooks", "--source", "react"]).unwrap();
2764
2765        if let Some(Commands::Search { sources, .. }) = cli.command {
2766            assert_eq!(sources, vec!["react"]);
2767        } else {
2768            panic!("Expected search command");
2769        }
2770    }
2771
2772    #[test]
2773    fn test_no_source_search_parsing() {
2774        use clap::Parser;
2775
2776        // Test no source (searches all)
2777        let cli = Cli::try_parse_from(vec!["blz", "search", "hooks"]).unwrap();
2778
2779        if let Some(Commands::Search { sources, .. }) = cli.command {
2780            assert!(sources.is_empty());
2781        } else {
2782            panic!("Expected search command");
2783        }
2784    }
2785
2786    #[test]
2787    fn test_multi_source_shorthand_parsing() {
2788        use clap::Parser;
2789
2790        // Test with -s shorthand
2791        let cli = Cli::try_parse_from(vec!["blz", "search", "api", "-s", "bun,node,deno"]).unwrap();
2792
2793        if let Some(Commands::Search { sources, .. }) = cli.command {
2794            assert_eq!(sources, vec!["bun", "node", "deno"]);
2795        } else {
2796            panic!("Expected search command");
2797        }
2798    }
2799
2800    #[test]
2801    fn test_get_command_with_source_flag() {
2802        use clap::Parser;
2803
2804        let cli = Cli::try_parse_from(vec![
2805            "blz", "get", "meta", "--lines", "1-3", "--source", "bun",
2806        ])
2807        .unwrap();
2808
2809        if let Some(Commands::Get {
2810            targets,
2811            source,
2812            lines,
2813            ..
2814        }) = cli.command
2815        {
2816            assert_eq!(targets, vec!["meta".to_string()]);
2817            assert_eq!(source.as_deref(), Some("bun"));
2818            assert_eq!(lines.as_deref(), Some("1-3"));
2819        } else {
2820            panic!("Expected get command");
2821        }
2822    }
2823
2824    #[test]
2825    fn test_get_command_with_source_shorthand() {
2826        use clap::Parser;
2827
2828        let cli = Cli::try_parse_from(vec!["blz", "get", "meta:4-6", "-s", "canonical"]).unwrap();
2829
2830        if let Some(Commands::Get {
2831            targets, source, ..
2832        }) = cli.command
2833        {
2834            assert_eq!(targets, vec!["meta:4-6".to_string()]);
2835            assert_eq!(source.as_deref(), Some("canonical"));
2836        } else {
2837            panic!("Expected get command");
2838        }
2839    }
2840}