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