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::collections::BTreeSet;
15use std::sync::{Arc, OnceLock};
16
17pub mod args;
18mod cli;
19mod commands;
20pub mod config;
21pub mod error;
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/// Preprocess command-line arguments so shorthand search syntax and format aliases work.
41///
42/// When search-only flags (for example `-s`, `--limit`, `--json`) are used without explicitly
43/// writing the `search` subcommand, we inject it and normalise aliases so clap parses them
44/// correctly.
45fn preprocess_args() -> Vec<String> {
46    let raw: Vec<String> = std::env::args().collect();
47    preprocess_args_from(&raw)
48}
49
50#[derive(Clone, Copy, PartialEq, Eq)]
51enum FlagKind {
52    Switch,
53    TakesValue,
54}
55
56#[derive(Clone, Debug, PartialEq, Eq)]
57enum SearchFlagMatch {
58    None,
59    RequiresValue {
60        flag: &'static str,
61        attached: Option<String>,
62    },
63    OptionalValue {
64        flag: &'static str,
65        attached: Option<String>,
66    },
67    NoValue(&'static str),
68    FormatAlias(&'static str),
69}
70
71fn preprocess_args_from(raw: &[String]) -> Vec<String> {
72    if raw.len() <= 1 {
73        return raw.to_vec();
74    }
75
76    let (first_non_global_idx, search_flag_found) = scan_args_for_search_flags(raw);
77    let explicit_subcommand =
78        first_non_global_idx < raw.len() && is_known_subcommand(raw[first_non_global_idx].as_str());
79    let should_inject_search = search_flag_found && !explicit_subcommand;
80
81    build_preprocessed_result(raw, first_non_global_idx, should_inject_search)
82}
83
84/// Scan arguments to find the first non-global index and detect search flags.
85///
86/// Returns (`first_non_global_idx`, `search_flag_found`).
87fn scan_args_for_search_flags(raw: &[String]) -> (usize, bool) {
88    let mut first_non_global_idx = raw.len();
89    let mut search_flag_found = false;
90    let mut idx = 1;
91
92    // Phase 1: Skip global flags, find first non-global
93    while idx < raw.len() {
94        let arg = raw[idx].as_str();
95        if arg == "--" {
96            break;
97        }
98
99        if let Some(kind) = classify_global_flag(arg) {
100            if kind == FlagKind::TakesValue && idx + 1 < raw.len() {
101                idx += 1;
102            }
103            idx += 1;
104            continue;
105        }
106
107        if first_non_global_idx == raw.len() {
108            first_non_global_idx = idx;
109        }
110
111        if !matches!(classify_search_flag(arg), SearchFlagMatch::None) {
112            search_flag_found = true;
113        }
114
115        idx += 1;
116    }
117
118    if first_non_global_idx == raw.len() && idx < raw.len() {
119        first_non_global_idx = idx;
120    }
121
122    // Phase 2: Continue scanning for additional search flags
123    for arg in raw.iter().skip(first_non_global_idx) {
124        if arg == "--" {
125            break;
126        }
127        if !matches!(classify_search_flag(arg), SearchFlagMatch::None) {
128            search_flag_found = true;
129        }
130    }
131
132    (first_non_global_idx, search_flag_found)
133}
134
135/// Build the preprocessed result vector with optional search injection.
136fn build_preprocessed_result(
137    raw: &[String],
138    first_non_global_idx: usize,
139    should_inject_search: bool,
140) -> Vec<String> {
141    let mut result = Vec::with_capacity(raw.len() + 4);
142
143    result.push(raw[0].clone());
144
145    // Copy leading global flags
146    for arg in raw.iter().take(first_non_global_idx).skip(1) {
147        result.push(arg.clone());
148    }
149
150    if should_inject_search {
151        result.push("search".to_string());
152    }
153
154    let (processed_idx, encountered_sentinel) =
155        normalize_remaining_args(raw, first_non_global_idx, should_inject_search, &mut result);
156
157    if encountered_sentinel {
158        result.extend(raw.iter().skip(processed_idx).cloned());
159    }
160
161    result
162}
163
164/// Normalize remaining arguments, handling search flag transformations.
165///
166/// Returns (`final_index`, `encountered_sentinel`).
167fn normalize_remaining_args(
168    raw: &[String],
169    start_idx: usize,
170    should_inject_search: bool,
171    result: &mut Vec<String>,
172) -> (usize, bool) {
173    let mut idx = start_idx;
174
175    while idx < raw.len() {
176        let arg = raw[idx].as_str();
177        if arg == "--" {
178            result.push(raw[idx].clone());
179            return (idx + 1, true);
180        }
181
182        idx = process_single_arg(raw, idx, should_inject_search, result);
183    }
184
185    (idx, false)
186}
187
188/// Process a single argument, applying normalization as needed.
189///
190/// Returns the next index to process.
191fn process_single_arg(
192    raw: &[String],
193    idx: usize,
194    should_inject_search: bool,
195    result: &mut Vec<String>,
196) -> usize {
197    let arg = raw[idx].as_str();
198
199    match classify_search_flag(arg) {
200        SearchFlagMatch::None => {
201            result.push(raw[idx].clone());
202            idx + 1
203        },
204        SearchFlagMatch::NoValue(flag) => {
205            result.push(flag.to_string());
206            idx + 1
207        },
208        SearchFlagMatch::FormatAlias(format) => {
209            // Only convert format aliases to --format when injecting search
210            if should_inject_search {
211                result.push("--format".to_string());
212                result.push(format.to_string());
213            } else {
214                result.push(raw[idx].clone());
215            }
216            idx + 1
217        },
218        SearchFlagMatch::RequiresValue { flag, attached } => {
219            result.push(flag.to_string());
220            if let Some(value) = attached {
221                result.push(value);
222                idx + 1
223            } else if idx + 1 < raw.len() {
224                result.push(raw[idx + 1].clone());
225                idx + 2
226            } else {
227                idx + 1
228            }
229        },
230        SearchFlagMatch::OptionalValue { flag, attached } => {
231            result.push(flag.to_string());
232            if let Some(value) = attached {
233                result.push(value);
234                idx + 1
235            } else if idx + 1 < raw.len() && !raw[idx + 1].starts_with('-') {
236                // Only consume next argument if it doesn't look like a flag
237                result.push(raw[idx + 1].clone());
238                idx + 2
239            } else {
240                // No value, rely on clap's default_missing_value
241                idx + 1
242            }
243        },
244    }
245}
246
247fn is_known_subcommand(value: &str) -> bool {
248    known_subcommands().contains(value)
249}
250
251const RESERVED_SUBCOMMANDS: &[&str] = &["toc", "anchors", "anchor"];
252
253fn known_subcommands() -> &'static BTreeSet<String> {
254    static CACHE: OnceLock<BTreeSet<String>> = OnceLock::new();
255    CACHE.get_or_init(|| {
256        let mut names = BTreeSet::new();
257        for sub in Cli::command().get_subcommands() {
258            names.insert(sub.get_name().to_owned());
259            for alias in sub.get_all_aliases() {
260                names.insert(alias.to_owned());
261            }
262        }
263        for extra in RESERVED_SUBCOMMANDS {
264            names.insert((*extra).to_owned());
265        }
266        names
267    })
268}
269
270fn classify_global_flag(arg: &str) -> Option<FlagKind> {
271    match arg {
272        "-v" | "--verbose" | "-q" | "--quiet" | "--debug" | "--profile" | "--no-color" | "-h"
273        | "--help" | "-V" | "--version" | "--flamegraph" => Some(FlagKind::Switch),
274        "--config" | "--config-dir" | "--prompt" => Some(FlagKind::TakesValue),
275        _ if arg.starts_with("--config=")
276            || arg.starts_with("--config-dir=")
277            || arg.starts_with("--prompt=") =>
278        {
279            Some(FlagKind::Switch)
280        },
281        _ => None,
282    }
283}
284
285/// Helper function to match flags with values (either required or optional)
286fn match_flag_with_value(
287    arg: &str,
288    flags: &[(&'static str, &'static str)],
289    optional: bool,
290) -> SearchFlagMatch {
291    for (flag, canonical) in flags {
292        if let Some(value) = arg.strip_prefix(&format!("{flag}=")) {
293            return if optional {
294                SearchFlagMatch::OptionalValue {
295                    flag: canonical,
296                    attached: Some(value.to_string()),
297                }
298            } else {
299                SearchFlagMatch::RequiresValue {
300                    flag: canonical,
301                    attached: Some(value.to_string()),
302                }
303            };
304        }
305        if arg == *flag {
306            return if optional {
307                SearchFlagMatch::OptionalValue {
308                    flag: canonical,
309                    attached: None,
310                }
311            } else {
312                SearchFlagMatch::RequiresValue {
313                    flag: canonical,
314                    attached: None,
315                }
316            };
317        }
318    }
319    SearchFlagMatch::None
320}
321
322/// Classify command-line arguments as search flags
323///
324/// Determines the type of search flag and how it should be processed during
325/// argument preprocessing for shorthand search syntax.
326///
327/// # Examples
328/// ```rust,ignore
329/// # use crate::SearchFlagMatch;
330/// # fn classify_search_flag(arg: &str) -> SearchFlagMatch { SearchFlagMatch::None }
331/// assert_eq!(classify_search_flag("--context"),
332///            SearchFlagMatch::OptionalValue { flag: "--context", attached: None });
333/// assert_eq!(classify_search_flag("-C5"),
334///            SearchFlagMatch::OptionalValue { flag: "-C", attached: Some("5".to_string()) });
335/// ```
336fn classify_search_flag(arg: &str) -> SearchFlagMatch {
337    // Try simple flags first (exact matches)
338    if let result @ (SearchFlagMatch::NoValue(_) | SearchFlagMatch::FormatAlias(_)) =
339        classify_simple_search_flag(arg)
340    {
341        return result;
342    }
343
344    // Try format aliases with attached values
345    if let Some(result) = classify_format_alias_with_value(arg) {
346        return result;
347    }
348
349    // Try long flags with values
350    let result = classify_long_search_flags(arg);
351    if !matches!(result, SearchFlagMatch::None) {
352        return result;
353    }
354
355    // Try short flags with optional/required values
356    classify_short_search_flags(arg)
357}
358
359/// Classify simple search flags (exact matches for switches and format aliases).
360fn classify_simple_search_flag(arg: &str) -> SearchFlagMatch {
361    match arg {
362        "--last" => SearchFlagMatch::NoValue("--last"),
363        "--next" => SearchFlagMatch::NoValue("--next"),
364        "--previous" => SearchFlagMatch::NoValue("--previous"),
365        "--all" => SearchFlagMatch::NoValue("--all"),
366        "--no-summary" => SearchFlagMatch::NoValue("--no-summary"),
367        "--block" => SearchFlagMatch::NoValue("--block"),
368        "--no-history" => SearchFlagMatch::NoValue("--no-history"),
369        "--copy" => SearchFlagMatch::NoValue("--copy"),
370        "--json" => SearchFlagMatch::FormatAlias("json"),
371        "--jsonl" => SearchFlagMatch::FormatAlias("jsonl"),
372        "--text" => SearchFlagMatch::FormatAlias("text"),
373        _ => SearchFlagMatch::None,
374    }
375}
376
377/// Classify format alias flags with attached values (e.g., --json=true).
378fn classify_format_alias_with_value(arg: &str) -> Option<SearchFlagMatch> {
379    const FORMAT_PREFIXES: &[(&str, &str)] = &[
380        ("--json=", "json"),
381        ("--jsonl=", "jsonl"),
382        ("--text=", "text"),
383    ];
384
385    for (prefix, format) in FORMAT_PREFIXES {
386        if let Some(value) = arg.strip_prefix(prefix) {
387            if !value.is_empty() {
388                return Some(SearchFlagMatch::FormatAlias(format));
389            }
390        }
391    }
392    None
393}
394
395// Flag tables for classify_long_search_flags
396const LONG_CONTEXT_FLAGS: &[(&str, &str)] = &[
397    ("--context", "--context"),
398    ("--after-context", "--after-context"),
399    ("--before-context", "--before-context"),
400];
401
402const LONG_REQUIRED_VALUE_FLAGS: &[(&str, &str)] = &[
403    ("--max-lines", "--max-lines"),
404    ("--max-chars", "--max-chars"),
405];
406
407const LONG_SEARCH_FLAGS: &[(&str, &str)] = &[
408    ("--alias", "--alias"),
409    ("--source", "--source"),
410    ("--limit", "--limit"),
411    ("--page", "--page"),
412    ("--top", "--top"),
413    ("--format", "--format"),
414    ("--output", "--output"),
415    ("--show", "--show"),
416    ("--score-precision", "--score-precision"),
417    ("--snippet-lines", "--snippet-lines"),
418];
419
420// Flag tables for classify_short_search_flags
421const SHORT_OPTIONAL_FLAGS: &[(&str, &str)] =
422    &[("-C", "-C"), ("-c", "-c"), ("-A", "-A"), ("-B", "-B")];
423
424const SHORT_REQUIRED_FLAGS: &[(&str, &str)] =
425    &[("-s", "-s"), ("-n", "-n"), ("-f", "-f"), ("-o", "-o")];
426
427/// Classify long search flags that take values.
428fn classify_long_search_flags(arg: &str) -> SearchFlagMatch {
429    // Try context flags (optional values)
430    let result = match_flag_with_value(arg, LONG_CONTEXT_FLAGS, true);
431    if !matches!(result, SearchFlagMatch::None) {
432        return result;
433    }
434
435    // Try flags that require explicit values
436    let result = match_flag_with_value(arg, LONG_REQUIRED_VALUE_FLAGS, false);
437    if !matches!(result, SearchFlagMatch::None) {
438        return result;
439    }
440
441    // Try general search flags
442    match_flag_with_value(arg, LONG_SEARCH_FLAGS, false)
443}
444
445/// Classify short search flags (e.g., -C5, -s bun).
446fn classify_short_search_flags(arg: &str) -> SearchFlagMatch {
447    // Try short context flags with optional attached values
448    if let Some(result) = match_short_flag(arg, SHORT_OPTIONAL_FLAGS, true) {
449        return result;
450    }
451
452    // Try short flags that require values
453    if let Some(result) = match_short_flag(arg, SHORT_REQUIRED_FLAGS, false) {
454        return result;
455    }
456
457    SearchFlagMatch::None
458}
459
460/// Match a short flag with optional/required value attachment.
461fn match_short_flag(
462    arg: &str,
463    flags: &[(&'static str, &'static str)],
464    optional: bool,
465) -> Option<SearchFlagMatch> {
466    for (prefix, canonical) in flags {
467        if arg == *prefix {
468            return Some(if optional {
469                SearchFlagMatch::OptionalValue {
470                    flag: canonical,
471                    attached: None,
472                }
473            } else {
474                SearchFlagMatch::RequiresValue {
475                    flag: canonical,
476                    attached: None,
477                }
478            });
479        }
480        if arg.starts_with(prefix) && arg.len() > prefix.len() {
481            let attached = Some(arg[prefix.len()..].to_string());
482            return Some(if optional {
483                SearchFlagMatch::OptionalValue {
484                    flag: canonical,
485                    attached,
486                }
487            } else {
488                SearchFlagMatch::RequiresValue {
489                    flag: canonical,
490                    attached,
491                }
492            });
493        }
494    }
495    None
496}
497
498/// Execute the blz CLI with the currently configured environment.
499///
500/// # Errors
501///
502/// Returns an error if CLI initialization, prompt emission, or command execution fails.
503pub async fn run() -> Result<()> {
504    // Convert Broken pipe panics into a clean exit
505    std::panic::set_hook(Box::new(|info| {
506        let msg = info.to_string();
507        if msg.contains("Broken pipe") || msg.contains("broken pipe") {
508            // Exit silently for pipeline truncation
509            std::process::exit(0);
510        }
511        // Default behavior: print to stderr
512        eprintln!("{msg}");
513    }));
514
515    // Spawn process guard as early as possible to catch orphaned processes
516    utils::process_guard::spawn_parent_exit_guard();
517
518    // Preprocess arguments to handle shorthand search with flags
519    let args = preprocess_args();
520    let mut cli = Cli::parse_from(args);
521
522    if let Some(target) = cli.prompt.clone() {
523        prompt::emit(&target, cli.command.as_ref())?;
524        return Ok(());
525    }
526
527    initialize_logging(&cli)?;
528
529    let args: Vec<String> = std::env::args().collect();
530    let mut cli_preferences = preferences::load();
531    apply_preference_defaults(&mut cli, &cli_preferences, &args);
532
533    let metrics = PerformanceMetrics::default();
534
535    #[cfg(feature = "flamegraph")]
536    let profiler_guard = start_flamegraph_if_requested(&cli);
537
538    execute_command(cli.clone(), metrics.clone(), &mut cli_preferences).await?;
539
540    #[cfg(feature = "flamegraph")]
541    stop_flamegraph_if_started(profiler_guard);
542
543    print_diagnostics(&cli, &metrics);
544
545    if let Err(err) = preferences::save(&cli_preferences) {
546        warn!("failed to persist CLI preferences: {err}");
547    }
548
549    Ok(())
550}
551
552fn initialize_logging(cli: &Cli) -> Result<()> {
553    // Base level from global flags
554    let mut level = if cli.verbose || cli.debug {
555        Level::DEBUG
556    } else if cli.quiet {
557        Level::ERROR
558    } else {
559        Level::WARN
560    };
561
562    // If the selected command is emitting machine-readable output, suppress info logs
563    // to keep stdout/stderr clean unless verbose/debug was explicitly requested.
564    let mut machine_output = false;
565    if !(cli.verbose || cli.debug) {
566        #[allow(deprecated)]
567        let command_format = match &cli.command {
568            Some(
569                Commands::Search { format, .. }
570                | Commands::Find { format, .. }
571                | Commands::List { format, .. }
572                | Commands::Stats { format, .. }
573                | Commands::History { format, .. }
574                | Commands::Lookup { format, .. }
575                | Commands::Get { format, .. }
576                | Commands::Info { format, .. }
577                | Commands::Completions { format, .. },
578            ) => Some(format.resolve(cli.quiet)),
579            Some(Commands::Query(args)) => Some(args.format.resolve(cli.quiet)),
580            Some(Commands::Map(args)) => Some(args.format.resolve(cli.quiet)),
581            Some(Commands::Check(args)) => Some(args.format.resolve(cli.quiet)),
582            _ => None,
583        };
584
585        if let Some(fmt) = command_format {
586            if matches!(
587                fmt,
588                crate::output::OutputFormat::Json | crate::output::OutputFormat::Jsonl
589            ) {
590                level = Level::ERROR;
591                machine_output = true;
592            }
593        }
594    }
595
596    let subscriber = FmtSubscriber::builder()
597        .with_max_level(level)
598        .with_target(false)
599        .with_thread_ids(false)
600        .with_thread_names(false)
601        .with_writer(std::io::stderr)
602        .finish();
603
604    tracing::subscriber::set_global_default(subscriber)?;
605
606    // Color control: disable when requested, NO_COLOR is set, or when emitting machine output
607    let env_no_color = std::env::var("NO_COLOR").ok().is_some();
608    if cli.no_color || env_no_color || machine_output {
609        color_control::set_override(false);
610    }
611    Ok(())
612}
613
614#[cfg(feature = "flamegraph")]
615fn start_flamegraph_if_requested(cli: &Cli) -> Option<pprof::ProfilerGuard<'static>> {
616    if cli.flamegraph {
617        match start_profiling() {
618            Ok(guard) => {
619                println!("🔥 CPU profiling started - flamegraph will be generated");
620                Some(guard)
621            },
622            Err(e) => {
623                eprintln!("Failed to start profiling: {e}");
624                None
625            },
626        }
627    } else {
628        None
629    }
630}
631
632#[cfg(feature = "flamegraph")]
633fn stop_flamegraph_if_started(guard: Option<pprof::ProfilerGuard<'static>>) {
634    if let Some(guard) = guard {
635        if let Err(e) = stop_profiling_and_report(&guard) {
636            eprintln!("Failed to generate flamegraph: {e}");
637        }
638    }
639}
640
641async fn execute_command(
642    cli: Cli,
643    metrics: PerformanceMetrics,
644    prefs: &mut CliPreferences,
645) -> Result<()> {
646    let quiet = cli.quiet;
647    match cli.command {
648        Some(Commands::Instruct) => {
649            prompt::emit("__global__", Some(&Commands::Instruct))?;
650            eprintln!("`blz instruct` is deprecated. Use `blz --prompt` instead.");
651        },
652        Some(Commands::Completions {
653            shell,
654            list,
655            format,
656        }) => {
657            handle_completions(shell, list, format.resolve(quiet));
658        },
659        Some(Commands::Docs { command }) => {
660            handle_docs(command, quiet, metrics.clone(), prefs).await?;
661        },
662        Some(Commands::ClaudePlugin { command }) => handle_claude_plugin(command)?,
663        Some(Commands::Alias { command }) => handle_alias(command).await?,
664        Some(Commands::Add(args)) => handle_add(args, quiet, metrics).await?,
665        Some(Commands::Lookup {
666            query,
667            format,
668            limit,
669        }) => {
670            dispatch_lookup(query, format, limit, quiet, metrics).await?;
671        },
672        Some(Commands::Registry { command }) => handle_registry(command, quiet, metrics).await?,
673        Some(cmd @ Commands::Search { .. }) => dispatch_search(cmd, quiet, metrics, prefs).await?,
674        Some(Commands::History {
675            limit,
676            format,
677            clear,
678            clear_before,
679        }) => {
680            dispatch_history(limit, &format, clear, clear_before.as_deref(), quiet, prefs)?;
681        },
682        Some(cmd @ Commands::Get { .. }) => dispatch_get(cmd, quiet).await?,
683        Some(Commands::Query(args)) => handle_query(args, quiet, prefs, metrics.clone()).await?,
684        Some(Commands::Map(args)) => dispatch_map(args, quiet).await?,
685        Some(Commands::Sync(args)) => dispatch_sync(args, quiet, metrics).await?,
686        Some(Commands::Check(args)) => {
687            commands::check_source(args.alias, args.all, args.format.resolve(quiet)).await?;
688        },
689        Some(Commands::Rm(args)) => commands::rm_source(vec![args.alias], args.yes).await?,
690        #[allow(deprecated)]
691        Some(cmd @ Commands::Find { .. }) => {
692            dispatch_find(cmd, quiet, prefs, metrics.clone()).await?;
693        },
694        Some(Commands::Info { alias, format }) => {
695            commands::execute_info(&alias, format.resolve(quiet)).await?;
696        },
697        Some(Commands::List {
698            format,
699            status,
700            details,
701            limit,
702        }) => {
703            dispatch_list(format, status, details, limit, quiet).await?;
704        },
705        Some(Commands::Stats { format, limit }) => {
706            commands::show_stats(format.resolve(quiet), limit)?;
707        },
708        #[allow(deprecated)]
709        Some(Commands::Validate { alias, all, format }) => {
710            dispatch_validate(alias, all, format, quiet).await?;
711        },
712        Some(Commands::Doctor { format, fix }) => {
713            commands::run_doctor(format.resolve(quiet), fix).await?;
714        },
715        #[allow(deprecated)]
716        Some(cmd @ Commands::Refresh { .. }) => dispatch_refresh(cmd, quiet, metrics).await?,
717        #[allow(deprecated)]
718        Some(cmd @ Commands::Update { .. }) => dispatch_update(cmd, quiet, metrics).await?,
719        #[allow(deprecated)]
720        Some(Commands::Remove { alias, yes }) => dispatch_remove(alias, yes, quiet).await?,
721        Some(Commands::Clear { force }) => commands::clear_cache(force)?,
722        Some(Commands::Diff { alias, since }) => {
723            commands::show_diff(&alias, since.as_deref()).await?;
724        },
725        Some(Commands::McpServer) => commands::mcp_server().await?,
726        Some(Commands::Anchor { command }) => handle_anchor(command, quiet).await?,
727        #[allow(deprecated)]
728        Some(cmd @ Commands::Toc { .. }) => dispatch_toc(cmd, quiet).await?,
729        None => commands::handle_default_search(&cli.query, metrics, None, prefs, quiet).await?,
730    }
731    Ok(())
732}
733
734/// Dispatch a Search command variant, handling destructuring internally.
735///
736/// This function extracts all fields from the `Commands::Search` variant and
737/// forwards them to the underlying `handle_search` implementation.
738#[allow(clippy::too_many_lines)]
739async fn dispatch_search(
740    cmd: Commands,
741    quiet: bool,
742    metrics: PerformanceMetrics,
743    prefs: &mut CliPreferences,
744) -> Result<()> {
745    let Commands::Search {
746        query,
747        sources,
748        next,
749        previous,
750        last,
751        limit,
752        all,
753        page,
754        top,
755        heading_level,
756        format,
757        show,
758        no_summary,
759        score_precision,
760        snippet_lines,
761        max_chars,
762        context,
763        context_deprecated,
764        after_context,
765        before_context,
766        block,
767        max_lines,
768        headings_only,
769        no_history,
770        copy,
771        timing,
772    } = cmd
773    else {
774        unreachable!("dispatch_search called with non-Search command");
775    };
776
777    let resolved_format = format.resolve(quiet);
778    let merged_context =
779        crate::cli::merge_context_flags(context, context_deprecated, after_context, before_context);
780
781    handle_search(
782        query,
783        sources,
784        next,
785        previous,
786        last,
787        limit,
788        all,
789        page,
790        top,
791        heading_level,
792        resolved_format,
793        show,
794        no_summary,
795        score_precision,
796        snippet_lines,
797        max_chars,
798        merged_context,
799        block,
800        max_lines,
801        headings_only,
802        no_history,
803        copy,
804        timing,
805        quiet,
806        metrics,
807        prefs,
808    )
809    .await
810}
811
812/// Dispatch a Get command variant, handling destructuring internally.
813#[allow(clippy::too_many_lines)]
814async fn dispatch_get(cmd: Commands, quiet: bool) -> Result<()> {
815    let Commands::Get {
816        targets,
817        lines,
818        source,
819        context,
820        context_deprecated,
821        after_context,
822        before_context,
823        block,
824        max_lines,
825        format,
826        copy,
827    } = cmd
828    else {
829        unreachable!("dispatch_get called with non-Get command");
830    };
831
832    handle_get(
833        targets,
834        lines,
835        source,
836        context,
837        context_deprecated,
838        after_context,
839        before_context,
840        block,
841        max_lines,
842        format.resolve(quiet),
843        copy,
844    )
845    .await
846}
847
848/// Dispatch a Find command variant, handling destructuring internally.
849#[allow(clippy::too_many_lines)]
850async fn dispatch_find(
851    cmd: Commands,
852    quiet: bool,
853    prefs: &mut CliPreferences,
854    metrics: PerformanceMetrics,
855) -> Result<()> {
856    #[allow(deprecated)]
857    let Commands::Find {
858        inputs,
859        sources,
860        limit,
861        all,
862        page,
863        top,
864        heading_level,
865        format,
866        show,
867        no_summary,
868        score_precision,
869        snippet_lines,
870        max_chars,
871        context,
872        context_deprecated,
873        after_context,
874        before_context,
875        block,
876        max_lines,
877        headings_only,
878        no_history,
879        copy,
880        timing,
881    } = cmd
882    else {
883        unreachable!("dispatch_find called with non-Find command");
884    };
885
886    handle_find(
887        inputs,
888        sources,
889        limit,
890        all,
891        page,
892        top,
893        heading_level,
894        format.resolve(quiet),
895        show,
896        no_summary,
897        score_precision,
898        snippet_lines,
899        max_chars,
900        context,
901        context_deprecated,
902        after_context,
903        before_context,
904        block,
905        max_lines,
906        headings_only,
907        no_history,
908        copy,
909        timing,
910        quiet,
911        prefs,
912        metrics,
913    )
914    .await
915}
916
917/// Dispatch a Toc command variant, handling destructuring internally.
918#[allow(deprecated)]
919async fn dispatch_toc(cmd: Commands, quiet: bool) -> Result<()> {
920    let Commands::Toc {
921        alias,
922        format,
923        filter,
924        max_depth,
925        heading_level,
926        sources,
927        all,
928        tree,
929        anchors,
930        show_anchors,
931        next,
932        previous,
933        last,
934        limit,
935        page,
936    } = cmd
937    else {
938        unreachable!("dispatch_toc called with non-Toc command");
939    };
940
941    handle_toc(
942        alias,
943        sources,
944        all,
945        format.resolve(quiet),
946        anchors,
947        show_anchors,
948        limit,
949        max_depth,
950        heading_level,
951        filter,
952        tree,
953        next,
954        previous,
955        last,
956        page,
957    )
958    .await
959}
960
961/// Dispatch a Refresh command variant, handling destructuring internally.
962#[allow(deprecated)]
963async fn dispatch_refresh(cmd: Commands, quiet: bool, metrics: PerformanceMetrics) -> Result<()> {
964    let Commands::Refresh {
965        aliases,
966        all,
967        yes: _,
968        reindex,
969        filter,
970        no_filter,
971    } = cmd
972    else {
973        unreachable!("dispatch_refresh called with non-Refresh command");
974    };
975
976    if !utils::cli_args::deprecation_warnings_suppressed() {
977        eprintln!(
978            "{}",
979            "Warning: 'refresh' is deprecated, use 'sync' instead".yellow()
980        );
981    }
982
983    handle_refresh(aliases, all, reindex, filter, no_filter, metrics, quiet).await
984}
985
986/// Dispatch a Map command.
987async fn dispatch_map(args: crate::cli::MapArgs, quiet: bool) -> Result<()> {
988    commands::show_map(
989        args.alias.as_deref(),
990        &args.sources,
991        args.all,
992        args.format.resolve(quiet),
993        args.anchors,
994        args.show_anchors,
995        args.limit,
996        args.max_depth,
997        args.heading_level.as_ref(),
998        args.filter.as_deref(),
999        args.tree,
1000        args.next,
1001        args.previous,
1002        args.last,
1003        args.page,
1004    )
1005    .await
1006}
1007
1008/// Dispatch a Sync command.
1009async fn dispatch_sync(
1010    args: crate::cli::SyncArgs,
1011    quiet: bool,
1012    metrics: PerformanceMetrics,
1013) -> Result<()> {
1014    commands::sync_source(
1015        args.aliases,
1016        args.all,
1017        args.yes,
1018        args.reindex,
1019        args.filter,
1020        args.no_filter,
1021        metrics,
1022        quiet,
1023    )
1024    .await
1025}
1026
1027/// Dispatch a History command.
1028fn dispatch_history(
1029    limit: usize,
1030    format: &crate::utils::cli_args::FormatArg,
1031    clear: bool,
1032    clear_before: Option<&str>,
1033    quiet: bool,
1034    prefs: &CliPreferences,
1035) -> Result<()> {
1036    commands::show_history(prefs, limit, format.resolve(quiet), clear, clear_before)
1037}
1038
1039/// Dispatch a List command.
1040async fn dispatch_list(
1041    format: crate::utils::cli_args::FormatArg,
1042    status: bool,
1043    details: bool,
1044    limit: Option<usize>,
1045    quiet: bool,
1046) -> Result<()> {
1047    commands::list_sources(format.resolve(quiet), status, details, limit).await
1048}
1049
1050/// Dispatch a Validate command (deprecated).
1051#[allow(deprecated)]
1052async fn dispatch_validate(
1053    alias: Option<String>,
1054    all: bool,
1055    format: crate::utils::cli_args::FormatArg,
1056    quiet: bool,
1057) -> Result<()> {
1058    if !utils::cli_args::deprecation_warnings_suppressed() {
1059        eprintln!(
1060            "{}",
1061            "Warning: 'validate' is deprecated, use 'check' instead".yellow()
1062        );
1063    }
1064    commands::validate_source(alias, all, format.resolve(quiet)).await
1065}
1066
1067/// Dispatch an Update command (deprecated).
1068#[allow(deprecated)]
1069async fn dispatch_update(cmd: Commands, quiet: bool, metrics: PerformanceMetrics) -> Result<()> {
1070    let Commands::Update {
1071        aliases,
1072        all,
1073        yes: _,
1074    } = cmd
1075    else {
1076        unreachable!("dispatch_update called with non-Update command");
1077    };
1078
1079    if !utils::cli_args::deprecation_warnings_suppressed() {
1080        eprintln!(
1081            "{}",
1082            "Warning: 'update' is deprecated, use 'refresh' instead".yellow()
1083        );
1084    }
1085    handle_refresh(aliases, all, false, None, false, metrics, quiet).await
1086}
1087
1088/// Dispatch a Remove command (deprecated).
1089#[allow(deprecated)]
1090async fn dispatch_remove(alias: String, yes: bool, quiet: bool) -> Result<()> {
1091    if !utils::cli_args::deprecation_warnings_suppressed() {
1092        eprintln!(
1093            "{}",
1094            "Warning: 'remove' is deprecated, use 'rm' instead".yellow()
1095        );
1096    }
1097    commands::remove_source(&alias, yes, quiet).await
1098}
1099
1100/// Dispatch a Lookup command.
1101async fn dispatch_lookup(
1102    query: String,
1103    format: crate::utils::cli_args::FormatArg,
1104    limit: Option<usize>,
1105    quiet: bool,
1106    metrics: PerformanceMetrics,
1107) -> Result<()> {
1108    commands::lookup_registry(&query, metrics, quiet, format.resolve(quiet), limit).await
1109}
1110
1111fn handle_completions(
1112    shell: Option<clap_complete::Shell>,
1113    list: bool,
1114    format: crate::output::OutputFormat,
1115) {
1116    if list {
1117        commands::list_supported(format);
1118    } else if let Some(shell) = shell {
1119        commands::generate(shell);
1120    } else {
1121        commands::list_supported(format);
1122    }
1123}
1124
1125async fn handle_add(
1126    args: crate::cli::AddArgs,
1127    quiet: bool,
1128    metrics: PerformanceMetrics,
1129) -> Result<()> {
1130    if let Some(manifest) = &args.manifest {
1131        commands::add_manifest(
1132            manifest,
1133            &args.only,
1134            metrics,
1135            commands::AddFlowOptions::new(args.dry_run, quiet, args.no_language_filter),
1136        )
1137        .await
1138    } else {
1139        let alias = args
1140            .alias
1141            .as_deref()
1142            .ok_or_else(|| anyhow!("alias is required when manifest is not provided"))?;
1143        let url = args
1144            .url
1145            .as_deref()
1146            .ok_or_else(|| anyhow!("url is required when manifest is not provided"))?;
1147
1148        let descriptor = DescriptorInput::from_cli_inputs(
1149            &args.aliases,
1150            args.name.as_deref(),
1151            args.description.as_deref(),
1152            args.category.as_deref(),
1153            &args.tags,
1154        );
1155
1156        let request = AddRequest::new(
1157            alias.to_string(),
1158            url.to_string(),
1159            descriptor,
1160            args.dry_run,
1161            quiet,
1162            metrics,
1163            args.no_language_filter,
1164        );
1165
1166        commands::add_source(request).await
1167    }
1168}
1169
1170#[allow(clippy::too_many_arguments)]
1171async fn handle_get(
1172    targets: Vec<String>,
1173    lines: Option<String>,
1174    source: Option<String>,
1175    context: Option<crate::cli::ContextMode>,
1176    context_deprecated: Option<crate::cli::ContextMode>,
1177    after_context: Option<usize>,
1178    before_context: Option<usize>,
1179    block: bool,
1180    max_lines: Option<usize>,
1181    format: crate::output::OutputFormat,
1182    copy: bool,
1183) -> Result<()> {
1184    let request_specs = parse_get_targets(&targets, lines.as_deref(), source)?;
1185
1186    let merged_context =
1187        crate::cli::merge_context_flags(context, context_deprecated, after_context, before_context);
1188
1189    commands::get_lines(
1190        &request_specs,
1191        merged_context.as_ref(),
1192        block,
1193        max_lines,
1194        format,
1195        copy,
1196    )
1197    .await
1198}
1199
1200/// Parse get command targets into request specifications.
1201fn parse_get_targets(
1202    targets: &[String],
1203    lines: Option<&str>,
1204    source: Option<String>,
1205) -> Result<Vec<RequestSpec>> {
1206    if targets.is_empty() {
1207        anyhow::bail!("At least one target is required. Use format: alias[:ranges]");
1208    }
1209
1210    if lines.is_some() && targets.len() > 1 {
1211        anyhow::bail!(
1212            "--lines can only be combined with a single alias. \
1213             Provide explicit ranges via colon syntax for each additional target."
1214        );
1215    }
1216
1217    let mut request_specs = Vec::with_capacity(targets.len());
1218    for (idx, target) in targets.iter().enumerate() {
1219        let spec = parse_single_target(target, idx, lines)?;
1220        request_specs.push(spec);
1221    }
1222
1223    apply_source_override(&mut request_specs, source)?;
1224    Ok(request_specs)
1225}
1226
1227/// Parse a single target string into a `RequestSpec`.
1228fn parse_single_target(target: &str, idx: usize, lines: Option<&str>) -> Result<RequestSpec> {
1229    let trimmed = target.trim();
1230    if trimmed.is_empty() {
1231        anyhow::bail!("Alias at position {} cannot be empty.", idx + 1);
1232    }
1233
1234    if let Some((alias_part, range_part)) = trimmed.split_once(':') {
1235        let trimmed_alias = alias_part.trim();
1236        if trimmed_alias.is_empty() {
1237            anyhow::bail!(
1238                "Alias at position {} cannot be empty. Use syntax like 'bun:120-142'.",
1239                idx + 1
1240            );
1241        }
1242        if range_part.is_empty() {
1243            anyhow::bail!(
1244                "Alias '{trimmed_alias}' is missing a range. \
1245                 Use syntax like '{trimmed_alias}:120-142'."
1246            );
1247        }
1248        Ok(RequestSpec {
1249            alias: trimmed_alias.to_string(),
1250            line_expression: range_part.trim().to_string(),
1251        })
1252    } else {
1253        let Some(line_expr) = lines else {
1254            anyhow::bail!(
1255                "Missing line specification for alias '{trimmed}'. \
1256                 Use '{trimmed}:1-3' or provide --lines."
1257            );
1258        };
1259        Ok(RequestSpec {
1260            alias: trimmed.to_string(),
1261            line_expression: line_expr.to_string(),
1262        })
1263    }
1264}
1265
1266/// Apply source override to request specs if provided.
1267fn apply_source_override(request_specs: &mut [RequestSpec], source: Option<String>) -> Result<()> {
1268    if let Some(explicit_source) = source {
1269        if request_specs.len() > 1 {
1270            anyhow::bail!("--source cannot be combined with multiple alias targets.");
1271        }
1272        if let Some(first) = request_specs.first_mut() {
1273            first.alias = explicit_source;
1274        }
1275    }
1276    Ok(())
1277}
1278
1279async fn handle_query(
1280    args: crate::cli::QueryArgs,
1281    quiet: bool,
1282    prefs: &mut CliPreferences,
1283    metrics: PerformanceMetrics,
1284) -> Result<()> {
1285    let resolved_format = args.format.resolve(quiet);
1286    let merged_context = crate::cli::merge_context_flags(
1287        args.context,
1288        args.context_deprecated,
1289        args.after_context,
1290        args.before_context,
1291    );
1292
1293    commands::query(
1294        &args.inputs,
1295        &args.sources,
1296        args.limit,
1297        args.all,
1298        args.page,
1299        false, // last - query command doesn't support --last flag
1300        args.top,
1301        args.heading_level.clone(),
1302        resolved_format,
1303        &args.show,
1304        args.no_summary,
1305        args.score_precision,
1306        args.snippet_lines,
1307        args.max_chars,
1308        merged_context.as_ref(),
1309        args.block,
1310        args.max_lines,
1311        args.no_history,
1312        args.copy,
1313        quiet,
1314        args.headings_only,
1315        args.timing,
1316        Some(prefs),
1317        metrics,
1318        None,
1319    )
1320    .await
1321}
1322
1323#[allow(clippy::too_many_arguments, clippy::fn_params_excessive_bools)]
1324async fn handle_find(
1325    inputs: Vec<String>,
1326    sources: Vec<String>,
1327    limit: Option<usize>,
1328    all: bool,
1329    page: usize,
1330    top: Option<u8>,
1331    heading_level: Option<String>,
1332    format: crate::output::OutputFormat,
1333    show: Vec<crate::cli::ShowComponent>,
1334    no_summary: bool,
1335    score_precision: Option<u8>,
1336    snippet_lines: u8,
1337    max_chars: Option<usize>,
1338    context: Option<crate::cli::ContextMode>,
1339    context_deprecated: Option<crate::cli::ContextMode>,
1340    after_context: Option<usize>,
1341    before_context: Option<usize>,
1342    block: bool,
1343    max_lines: Option<usize>,
1344    headings_only: bool,
1345    no_history: bool,
1346    copy: bool,
1347    timing: bool,
1348    quiet: bool,
1349    prefs: &mut CliPreferences,
1350    metrics: PerformanceMetrics,
1351) -> Result<()> {
1352    if !utils::cli_args::deprecation_warnings_suppressed() {
1353        eprintln!(
1354            "{}",
1355            "Warning: 'find' is deprecated, use 'query' or 'get' instead".yellow()
1356        );
1357    }
1358
1359    let merged_context =
1360        crate::cli::merge_context_flags(context, context_deprecated, after_context, before_context);
1361
1362    commands::find(
1363        &inputs,
1364        &sources,
1365        limit,
1366        all,
1367        page,
1368        false, // last - find command doesn't support --last flag
1369        top,
1370        heading_level,
1371        format,
1372        &show,
1373        no_summary,
1374        score_precision,
1375        snippet_lines,
1376        max_chars,
1377        merged_context.as_ref(),
1378        block,
1379        max_lines,
1380        no_history,
1381        copy,
1382        quiet,
1383        headings_only,
1384        timing,
1385        Some(prefs),
1386        metrics,
1387        None,
1388    )
1389    .await
1390}
1391
1392#[allow(clippy::too_many_arguments, clippy::fn_params_excessive_bools)]
1393async fn handle_toc(
1394    alias: Option<String>,
1395    sources: Vec<String>,
1396    all: bool,
1397    format: crate::output::OutputFormat,
1398    anchors: bool,
1399    show_anchors: bool,
1400    limit: Option<usize>,
1401    max_depth: Option<u8>,
1402    heading_level: Option<crate::utils::heading_filter::HeadingLevelFilter>,
1403    filter: Option<String>,
1404    tree: bool,
1405    next: bool,
1406    previous: bool,
1407    last: bool,
1408    page: usize,
1409) -> Result<()> {
1410    if !utils::cli_args::deprecation_warnings_suppressed() {
1411        eprintln!(
1412            "{}",
1413            "Warning: 'toc' is deprecated, use 'map' instead".yellow()
1414        );
1415    }
1416
1417    commands::show_toc(
1418        alias.as_deref(),
1419        &sources,
1420        all,
1421        format,
1422        anchors,
1423        show_anchors,
1424        limit,
1425        max_depth,
1426        heading_level.as_ref(),
1427        filter.as_deref(),
1428        tree,
1429        next,
1430        previous,
1431        last,
1432        page,
1433    )
1434    .await
1435}
1436
1437async fn handle_docs(
1438    command: Option<DocsCommands>,
1439    quiet: bool,
1440    metrics: PerformanceMetrics,
1441    _prefs: &mut CliPreferences,
1442) -> Result<()> {
1443    match command {
1444        Some(DocsCommands::Search(args)) => docs_search(args, quiet, metrics.clone()).await?,
1445        Some(DocsCommands::Sync {
1446            force,
1447            quiet: sync_quiet,
1448        }) => docs_sync(force, sync_quiet, metrics.clone())?,
1449        Some(DocsCommands::Overview) => {
1450            docs_overview(quiet, metrics.clone())?;
1451        },
1452        Some(DocsCommands::Cat) => {
1453            docs_cat(metrics.clone())?;
1454        },
1455        Some(DocsCommands::Export { format }) => {
1456            docs_export(Some(format))?;
1457        },
1458        None => {
1459            // When no subcommand is provided, show overview
1460            docs_overview(quiet, metrics.clone())?;
1461        },
1462    }
1463
1464    Ok(())
1465}
1466
1467fn handle_claude_plugin(command: ClaudePluginCommands) -> Result<()> {
1468    match command {
1469        ClaudePluginCommands::Install { scope } => {
1470            commands::install_local_plugin(scope)?;
1471        },
1472    }
1473
1474    Ok(())
1475}
1476
1477async fn docs_search(args: DocsSearchArgs, quiet: bool, metrics: PerformanceMetrics) -> Result<()> {
1478    sync_and_report(false, quiet, metrics.clone())?;
1479    let query = args.query.join(" ").trim().to_string();
1480    if query.is_empty() {
1481        anyhow::bail!("Search query cannot be empty");
1482    }
1483
1484    // Resolve format once before checking placeholder content
1485    let format = args.format.resolve(quiet);
1486
1487    // Check if the bundled docs contain placeholder content
1488    let storage = Storage::new()?;
1489    if let Ok(content_path) = storage.llms_txt_path(BUNDLED_ALIAS) {
1490        if let Ok(content) = std::fs::read_to_string(&content_path) {
1491            if content.contains("# BLZ bundled docs (placeholder)") {
1492                let error_msg = if matches!(format, crate::output::OutputFormat::Json) {
1493                    // JSON output: structured error message
1494                    let error_json = serde_json::json!({
1495                        "error": "Bundled documentation content not yet available",
1496                        "reason": "The blz-docs source currently contains placeholder content",
1497                        "suggestions": [
1498                            "Use 'blz docs overview' for quick-start information",
1499                            "Use 'blz docs export' to view CLI documentation",
1500                            "Full bundled documentation will be included in a future release"
1501                        ]
1502                    });
1503                    return Err(anyhow!(serde_json::to_string_pretty(&error_json)?));
1504                } else {
1505                    // Text output: user-friendly message
1506                    "Bundled documentation content not yet available.\n\
1507                     \n\
1508                     The blz-docs source currently contains placeholder content.\n\
1509                     Full documentation will be included in a future release.\n\
1510                     \n\
1511                     Available alternatives:\n\
1512                     • Run 'blz docs overview' for quick-start information\n\
1513                     • Run 'blz docs export' to view CLI documentation\n\
1514                     • Run 'blz docs cat' to view the current placeholder content"
1515                };
1516                anyhow::bail!("{error_msg}");
1517            }
1518        }
1519    }
1520
1521    let sources = vec![BUNDLED_ALIAS.to_string()];
1522
1523    // Convert docs search args context to ContextMode
1524    let context_mode = args.context.map(crate::cli::ContextMode::Symmetric);
1525
1526    commands::search(
1527        &query,
1528        &sources,
1529        false,
1530        args.limit,
1531        1,
1532        args.top,
1533        None, // heading_level - not supported in bare command mode
1534        format,
1535        &args.show,
1536        args.no_summary,
1537        args.score_precision,
1538        args.snippet_lines,
1539        args.max_chars.unwrap_or(commands::DEFAULT_MAX_CHARS),
1540        context_mode.as_ref(),
1541        args.block,
1542        args.max_block_lines,
1543        false,
1544        true,
1545        args.copy,
1546        false, // timing
1547        quiet,
1548        None,
1549        metrics,
1550        None,
1551    )
1552    .await
1553}
1554
1555fn docs_sync(force: bool, quiet: bool, metrics: PerformanceMetrics) -> Result<()> {
1556    let status = sync_and_report(force, quiet, metrics)?;
1557    if !quiet && matches!(status, DocsSyncStatus::Installed | DocsSyncStatus::Updated) {
1558        let storage = Storage::new()?;
1559        let llms_path = storage.llms_txt_path(BUNDLED_ALIAS)?;
1560        println!("Bundled docs stored at {}", llms_path.display());
1561    }
1562    Ok(())
1563}
1564
1565fn docs_overview(quiet: bool, metrics: PerformanceMetrics) -> Result<()> {
1566    let status = sync_and_report(false, quiet, metrics)?;
1567    if !quiet {
1568        let storage = Storage::new()?;
1569        let llms_path = storage.llms_txt_path(BUNDLED_ALIAS)?;
1570        println!("Bundled docs status: {status:?}");
1571        println!("Alias: {BUNDLED_ALIAS} (also @blz)");
1572        println!("Stored at: {}", llms_path.display());
1573    }
1574    print_overview();
1575    Ok(())
1576}
1577
1578fn docs_cat(metrics: PerformanceMetrics) -> Result<()> {
1579    sync_and_report(false, true, metrics)?;
1580    print_full_content();
1581    Ok(())
1582}
1583
1584fn docs_export(format: Option<crate::commands::DocsFormat>) -> Result<()> {
1585    let requested = format.unwrap_or(crate::commands::DocsFormat::Markdown);
1586    let effective = match (std::env::var("BLZ_OUTPUT_FORMAT").ok(), requested) {
1587        (Some(v), crate::commands::DocsFormat::Markdown) if v.eq_ignore_ascii_case("json") => {
1588            crate::commands::DocsFormat::Json
1589        },
1590        _ => requested,
1591    };
1592    commands::generate_docs(effective)
1593}
1594
1595fn sync_and_report(
1596    force: bool,
1597    quiet: bool,
1598    metrics: PerformanceMetrics,
1599) -> Result<DocsSyncStatus> {
1600    let status = sync_bundled_docs(force, metrics)?;
1601    if !quiet {
1602        match status {
1603            DocsSyncStatus::UpToDate => {
1604                println!("Bundled docs already up to date");
1605            },
1606            DocsSyncStatus::Installed => {
1607                println!("Installed bundled docs source: {BUNDLED_ALIAS}");
1608            },
1609            DocsSyncStatus::Updated => {
1610                println!("Updated bundled docs source: {BUNDLED_ALIAS}");
1611            },
1612        }
1613    }
1614    Ok(status)
1615}
1616
1617async fn handle_anchor(command: AnchorCommands, quiet: bool) -> Result<()> {
1618    match command {
1619        AnchorCommands::List {
1620            alias,
1621            format,
1622            anchors,
1623            limit,
1624            max_depth,
1625            filter,
1626        } => {
1627            commands::show_toc(
1628                Some(&alias),
1629                &[],
1630                false,
1631                format.resolve(quiet),
1632                anchors,
1633                false, // show_anchors - not applicable in anchor list mode
1634                limit,
1635                max_depth,
1636                None,
1637                filter.as_deref(),
1638                false,
1639                false, // next
1640                false, // previous
1641                false, // last
1642                1,     // page
1643            )
1644            .await
1645        },
1646        AnchorCommands::Get {
1647            alias,
1648            anchor,
1649            context,
1650            format,
1651        } => commands::get_by_anchor(&alias, &anchor, context, format.resolve(quiet)).await,
1652    }
1653}
1654
1655async fn handle_alias(command: AliasCommands) -> Result<()> {
1656    match command {
1657        AliasCommands::Add { source, alias } => {
1658            commands::manage_alias(commands::AliasCommand::Add { source, alias }).await
1659        },
1660        AliasCommands::Rm { source, alias } => {
1661            commands::manage_alias(commands::AliasCommand::Rm { source, alias }).await
1662        },
1663    }
1664}
1665
1666async fn handle_registry(
1667    command: RegistryCommands,
1668    quiet: bool,
1669    metrics: PerformanceMetrics,
1670) -> Result<()> {
1671    match command {
1672        RegistryCommands::CreateSource {
1673            name,
1674            url,
1675            description,
1676            category,
1677            tags,
1678            npm,
1679            github,
1680            add,
1681            yes,
1682        } => {
1683            commands::create_registry_source(
1684                &name,
1685                &url,
1686                description,
1687                category,
1688                tags,
1689                npm,
1690                github,
1691                add,
1692                yes,
1693                quiet,
1694                metrics,
1695            )
1696            .await
1697        },
1698    }
1699}
1700
1701#[allow(clippy::too_many_arguments, clippy::fn_params_excessive_bools)]
1702async fn handle_search(
1703    query: Option<String>,
1704    sources: Vec<String>,
1705    next: bool,
1706    previous: bool,
1707    last: bool,
1708    limit: Option<usize>,
1709    all: bool,
1710    page: usize,
1711    top: Option<u8>,
1712    heading_level: Option<String>,
1713    format: crate::output::OutputFormat,
1714    show: Vec<crate::cli::ShowComponent>,
1715    no_summary: bool,
1716    score_precision: Option<u8>,
1717    snippet_lines: u8,
1718    max_chars: Option<usize>,
1719    context: Option<crate::cli::ContextMode>,
1720    block: bool,
1721    max_lines: Option<usize>,
1722    headings_only: bool,
1723    no_history: bool,
1724    copy: bool,
1725    timing: bool,
1726    quiet: bool,
1727    metrics: PerformanceMetrics,
1728    prefs: &mut CliPreferences,
1729) -> Result<()> {
1730    const DEFAULT_LIMIT: usize = 50;
1731    const ALL_RESULTS_LIMIT: usize = 10_000;
1732    const DEFAULT_SNIPPET_LINES: u8 = 3;
1733
1734    let provided_query = query.is_some();
1735    let limit_was_explicit = all || limit.is_some();
1736    let mut use_headings_only = headings_only;
1737
1738    // Emit deprecation warning if --snippet-lines was explicitly set
1739    if snippet_lines != DEFAULT_SNIPPET_LINES {
1740        let args: Vec<String> = std::env::args().collect();
1741        if flag_present(&args, "--snippet-lines") || std::env::var("BLZ_SNIPPET_LINES").is_ok() {
1742            // Pass false for quiet - the deprecation function handles quiet mode internally
1743            utils::cli_args::emit_snippet_lines_deprecation(false);
1744        }
1745    }
1746
1747    if next {
1748        validate_continuation_flag("--next", provided_query, &sources, page, last)?;
1749    }
1750
1751    if previous {
1752        validate_continuation_flag("--previous", provided_query, &sources, page, last)?;
1753    }
1754
1755    let history_entry = if next || previous || !provided_query {
1756        let mut records = utils::history_log::recent_for_active_scope(1);
1757        if records.is_empty() {
1758            anyhow::bail!("No previous search found. Use 'blz search <query>' first.");
1759        }
1760        Some(records.remove(0))
1761    } else {
1762        None
1763    };
1764
1765    if let Some(entry) = history_entry.as_ref() {
1766        if (next || previous) && headings_only != entry.headings_only {
1767            anyhow::bail!(
1768                "Cannot change --headings-only while using --next/--previous. Rerun without continuation flags."
1769            );
1770        }
1771        if !headings_only {
1772            use_headings_only = entry.headings_only;
1773        }
1774    }
1775
1776    let actual_query = resolve_query(query, history_entry.as_ref())?;
1777    let actual_sources = resolve_sources(sources, history_entry.as_ref());
1778
1779    let base_limit = if all {
1780        ALL_RESULTS_LIMIT
1781    } else {
1782        limit.unwrap_or(DEFAULT_LIMIT)
1783    };
1784    let actual_max_chars = max_chars.map_or(commands::DEFAULT_MAX_CHARS, commands::clamp_max_chars);
1785
1786    let (actual_page, actual_limit) = if let Some(entry) = history_entry.as_ref() {
1787        let adj = apply_history_pagination(
1788            entry,
1789            next,
1790            previous,
1791            provided_query,
1792            all,
1793            limit,
1794            limit_was_explicit,
1795            page,
1796            base_limit,
1797            ALL_RESULTS_LIMIT,
1798        )?;
1799        (adj.page, adj.limit)
1800    } else {
1801        (page, base_limit)
1802    };
1803
1804    commands::search(
1805        &actual_query,
1806        &actual_sources,
1807        last,
1808        actual_limit,
1809        actual_page,
1810        top,
1811        heading_level.clone(),
1812        format,
1813        &show,
1814        no_summary,
1815        score_precision,
1816        snippet_lines,
1817        actual_max_chars,
1818        context.as_ref(),
1819        block,
1820        max_lines,
1821        use_headings_only,
1822        no_history,
1823        copy,
1824        timing,
1825        quiet,
1826        Some(prefs),
1827        metrics,
1828        None,
1829    )
1830    .await
1831}
1832
1833#[allow(clippy::fn_params_excessive_bools)]
1834async fn handle_refresh(
1835    aliases: Vec<String>,
1836    all: bool,
1837    reindex: bool,
1838    filter: Option<String>,
1839    no_filter: bool,
1840    metrics: PerformanceMetrics,
1841    quiet: bool,
1842) -> Result<()> {
1843    let mut aliases = aliases;
1844    let mut filter = filter;
1845
1846    if !all && aliases.is_empty() {
1847        if let Some(raw_value) = filter.take() {
1848            if crate::utils::filter_flags::is_known_filter_expression(&raw_value) {
1849                filter = Some(raw_value);
1850            } else {
1851                aliases.push(raw_value);
1852                filter = Some(String::from("all"));
1853            }
1854        }
1855    }
1856
1857    if all || aliases.is_empty() {
1858        return commands::refresh_all(metrics, quiet, reindex, filter.as_ref(), no_filter).await;
1859    }
1860
1861    for alias in aliases {
1862        let metrics_clone = PerformanceMetrics {
1863            search_count: Arc::clone(&metrics.search_count),
1864            total_search_time: Arc::clone(&metrics.total_search_time),
1865            index_build_count: Arc::clone(&metrics.index_build_count),
1866            total_index_time: Arc::clone(&metrics.total_index_time),
1867            bytes_processed: Arc::clone(&metrics.bytes_processed),
1868            lines_searched: Arc::clone(&metrics.lines_searched),
1869        };
1870        commands::refresh_source(
1871            &alias,
1872            metrics_clone,
1873            quiet,
1874            reindex,
1875            filter.as_ref(),
1876            no_filter,
1877        )
1878        .await?;
1879    }
1880
1881    Ok(())
1882}
1883
1884fn print_diagnostics(cli: &Cli, metrics: &PerformanceMetrics) {
1885    if cli.debug {
1886        metrics.print_summary();
1887    }
1888}
1889
1890fn apply_preference_defaults(cli: &mut Cli, prefs: &CliPreferences, args: &[String]) {
1891    if let Some(Commands::Search {
1892        show,
1893        score_precision,
1894        snippet_lines,
1895        ..
1896    }) = cli.command.as_mut()
1897    {
1898        let show_env = std::env::var("BLZ_SHOW").is_ok();
1899        if show.is_empty() && !flag_present(args, "--show") && !show_env {
1900            *show = prefs.default_show_components();
1901        }
1902
1903        if score_precision.is_none()
1904            && !flag_present(args, "--score-precision")
1905            && std::env::var("BLZ_SCORE_PRECISION").is_err()
1906        {
1907            *score_precision = Some(prefs.default_score_precision());
1908        }
1909
1910        if !flag_present(args, "--snippet-lines") && std::env::var("BLZ_SNIPPET_LINES").is_err() {
1911            *snippet_lines = prefs.default_snippet_lines();
1912        }
1913    }
1914}
1915
1916fn flag_present(args: &[String], flag: &str) -> bool {
1917    let flag_eq = flag;
1918    let flag_eq_with_equal = format!("{flag}=");
1919    args.iter()
1920        .any(|arg| arg == flag_eq || arg.starts_with(&flag_eq_with_equal))
1921}
1922
1923/// Pagination adjustments computed from history and continuation flags.
1924struct PaginationAdjustment {
1925    page: usize,
1926    limit: usize,
1927}
1928
1929/// Apply history-based pagination adjustments for --next/--previous flags.
1930///
1931/// Returns adjusted page and limit values based on history entry and continuation mode.
1932#[allow(clippy::too_many_arguments, clippy::fn_params_excessive_bools)]
1933fn apply_history_pagination(
1934    entry: &utils::preferences::SearchHistoryEntry,
1935    next: bool,
1936    previous: bool,
1937    provided_query: bool,
1938    all: bool,
1939    limit: Option<usize>,
1940    limit_was_explicit: bool,
1941    current_page: usize,
1942    current_limit: usize,
1943    all_results_limit: usize,
1944) -> Result<PaginationAdjustment> {
1945    let mut actual_page = current_page;
1946    let mut actual_limit = current_limit;
1947
1948    if next {
1949        validate_history_has_results(entry)?;
1950        validate_pagination_limit_consistency(
1951            "--next",
1952            all,
1953            limit_was_explicit,
1954            limit,
1955            entry.limit,
1956            all_results_limit,
1957        )?;
1958
1959        if let (Some(prev_page), Some(total_pages)) = (entry.page, entry.total_pages) {
1960            if prev_page >= total_pages {
1961                anyhow::bail!("Already at the last page (page {prev_page} of {total_pages})");
1962            }
1963            actual_page = prev_page + 1;
1964        } else {
1965            actual_page = entry.page.unwrap_or(1) + 1;
1966        }
1967
1968        if !limit_was_explicit {
1969            actual_limit = entry.limit.unwrap_or(actual_limit);
1970        }
1971    } else if previous {
1972        validate_history_has_results(entry)?;
1973        validate_pagination_limit_consistency(
1974            "--previous",
1975            all,
1976            limit_was_explicit,
1977            limit,
1978            entry.limit,
1979            all_results_limit,
1980        )?;
1981
1982        if let Some(prev_page) = entry.page {
1983            if prev_page <= 1 {
1984                anyhow::bail!("Already on first page");
1985            }
1986            actual_page = prev_page - 1;
1987        } else {
1988            anyhow::bail!("No previous page found in search history");
1989        }
1990
1991        if !limit_was_explicit {
1992            actual_limit = entry.limit.unwrap_or(actual_limit);
1993        }
1994    } else if !provided_query && !limit_was_explicit {
1995        actual_limit = entry.limit.unwrap_or(actual_limit);
1996    }
1997
1998    Ok(PaginationAdjustment {
1999        page: actual_page,
2000        limit: actual_limit,
2001    })
2002}
2003
2004/// Validate that history entry has non-zero results.
2005fn validate_history_has_results(entry: &utils::preferences::SearchHistoryEntry) -> Result<()> {
2006    if matches!(entry.total_pages, Some(0)) || matches!(entry.total_results, Some(0)) {
2007        anyhow::bail!(
2008            "Previous search returned 0 results. Rerun with a different query or source."
2009        );
2010    }
2011    Ok(())
2012}
2013
2014/// Resolve the search query from explicit input or history.
2015fn resolve_query(
2016    mut query: Option<String>,
2017    history: Option<&utils::preferences::SearchHistoryEntry>,
2018) -> Result<String> {
2019    if let Some(value) = query.take() {
2020        Ok(value)
2021    } else if let Some(entry) = history {
2022        Ok(entry.query.clone())
2023    } else {
2024        anyhow::bail!("No previous search found. Use 'blz search <query>' first.");
2025    }
2026}
2027
2028/// Resolve source filters from explicit input or history.
2029fn resolve_sources(
2030    sources: Vec<String>,
2031    history: Option<&utils::preferences::SearchHistoryEntry>,
2032) -> Vec<String> {
2033    if !sources.is_empty() {
2034        sources
2035    } else if let Some(entry) = history {
2036        entry.source.as_ref().map_or_else(Vec::new, |source_str| {
2037            source_str
2038                .split(',')
2039                .map(|s| s.trim().to_string())
2040                .collect()
2041        })
2042    } else {
2043        Vec::new()
2044    }
2045}
2046
2047/// Validate that pagination limit hasn't changed when using continuation flags.
2048fn validate_pagination_limit_consistency(
2049    flag: &str,
2050    all: bool,
2051    limit_was_explicit: bool,
2052    limit: Option<usize>,
2053    history_limit: Option<usize>,
2054    all_results_limit: usize,
2055) -> Result<()> {
2056    let history_all = history_limit.is_some_and(|value| value >= all_results_limit);
2057    if all != history_all {
2058        anyhow::bail!(
2059            "Cannot use {flag} when changing page size or --all; rerun without {flag} or reuse the previous pagination flags."
2060        );
2061    }
2062    if limit_was_explicit {
2063        if let Some(requested_limit) = limit {
2064            if history_limit != Some(requested_limit) {
2065                anyhow::bail!(
2066                    "Cannot use {flag} when changing page size; rerun without {flag} or reuse the previous limit."
2067                );
2068            }
2069        }
2070    }
2071    Ok(())
2072}
2073
2074/// Validate that a continuation flag (--next/--previous) is not combined with incompatible options.
2075fn validate_continuation_flag(
2076    flag: &str,
2077    provided_query: bool,
2078    sources: &[String],
2079    page: usize,
2080    last: bool,
2081) -> Result<()> {
2082    if provided_query {
2083        anyhow::bail!(
2084            "Cannot combine {flag} with an explicit query. Remove the query to continue from the previous search."
2085        );
2086    }
2087    if !sources.is_empty() {
2088        anyhow::bail!(
2089            "Cannot combine {flag} with --source. Omit --source to reuse the last search context."
2090        );
2091    }
2092    if page != 1 {
2093        anyhow::bail!("Cannot combine {flag} with --page. Use one pagination option at a time.");
2094    }
2095    if last {
2096        anyhow::bail!("Cannot combine {flag} with --last. Choose a single continuation flag.");
2097    }
2098    Ok(())
2099}
2100
2101#[cfg(test)]
2102#[allow(
2103    clippy::unwrap_used,
2104    clippy::panic,
2105    clippy::disallowed_macros,
2106    clippy::needless_collect,
2107    clippy::unnecessary_wraps,
2108    clippy::deref_addrof
2109)]
2110mod tests {
2111    use super::*;
2112    use crate::utils::constants::RESERVED_KEYWORDS;
2113    use crate::utils::parsing::{LineRange, parse_line_ranges};
2114    use crate::utils::validation::validate_alias;
2115    use std::collections::HashSet;
2116
2117    #[test]
2118    fn test_reserved_keywords_validation() {
2119        for &keyword in RESERVED_KEYWORDS {
2120            let result = validate_alias(keyword);
2121            assert!(
2122                result.is_err(),
2123                "Reserved keyword '{keyword}' should be rejected"
2124            );
2125
2126            let error_msg = result.unwrap_err().to_string();
2127            assert!(
2128                error_msg.contains(keyword),
2129                "Error message should contain the reserved keyword '{keyword}'"
2130            );
2131        }
2132    }
2133
2134    #[test]
2135    fn test_valid_aliases_allowed() {
2136        let valid_aliases = ["react", "nextjs", "python", "rust", "docs", "api", "guide"];
2137
2138        for &alias in &valid_aliases {
2139            let result = validate_alias(alias);
2140            assert!(result.is_ok(), "Valid alias '{alias}' should be accepted");
2141        }
2142    }
2143
2144    #[test]
2145    fn test_language_names_are_not_reserved() {
2146        let language_names = [
2147            "node",
2148            "python",
2149            "rust",
2150            "go",
2151            "java",
2152            "javascript",
2153            "typescript",
2154        ];
2155
2156        for &lang in &language_names {
2157            assert!(
2158                !RESERVED_KEYWORDS.contains(&lang),
2159                "Language name '{lang}' should not be reserved"
2160            );
2161
2162            let result = validate_alias(lang);
2163            assert!(
2164                result.is_ok(),
2165                "Language name '{lang}' should be usable as alias"
2166            );
2167        }
2168    }
2169
2170    #[test]
2171    fn test_reserved_keywords_case_insensitive() {
2172        let result = validate_alias("ADD");
2173        assert!(
2174            result.is_err(),
2175            "Reserved keyword 'ADD' (uppercase) should be rejected"
2176        );
2177
2178        let result = validate_alias("Add");
2179        assert!(
2180            result.is_err(),
2181            "Reserved keyword 'Add' (mixed case) should be rejected"
2182        );
2183    }
2184
2185    #[test]
2186    fn test_line_range_parsing() {
2187        // Single line
2188        let ranges = parse_line_ranges("42").expect("Should parse single line");
2189        assert_eq!(ranges.len(), 1);
2190        assert!(matches!(ranges[0], LineRange::Single(42)));
2191
2192        // Colon range
2193        let ranges = parse_line_ranges("120:142").expect("Should parse colon range");
2194        assert_eq!(ranges.len(), 1);
2195        assert!(matches!(ranges[0], LineRange::Range(120, 142)));
2196
2197        // Dash range
2198        let ranges = parse_line_ranges("120-142").expect("Should parse dash range");
2199        assert_eq!(ranges.len(), 1);
2200        assert!(matches!(ranges[0], LineRange::Range(120, 142)));
2201
2202        // Plus syntax
2203        let ranges = parse_line_ranges("36+20").expect("Should parse plus syntax");
2204        assert_eq!(ranges.len(), 1);
2205        assert!(matches!(ranges[0], LineRange::PlusCount(36, 20)));
2206
2207        // Multiple ranges
2208        let ranges =
2209            parse_line_ranges("36:43,120-142,200+10").expect("Should parse multiple ranges");
2210        assert_eq!(ranges.len(), 3);
2211    }
2212
2213    #[test]
2214    fn test_line_range_parsing_errors() {
2215        assert!(parse_line_ranges("0").is_err(), "Line 0 should be invalid");
2216        assert!(
2217            parse_line_ranges("50:30").is_err(),
2218            "Backwards range should be invalid"
2219        );
2220        assert!(
2221            parse_line_ranges("50+0").is_err(),
2222            "Plus zero count should be invalid"
2223        );
2224        assert!(
2225            parse_line_ranges("abc").is_err(),
2226            "Invalid format should be rejected"
2227        );
2228    }
2229
2230    #[test]
2231    fn test_reserved_keywords_no_duplicates() {
2232        let mut seen = HashSet::new();
2233        for &keyword in RESERVED_KEYWORDS {
2234            assert!(
2235                seen.insert(keyword),
2236                "Reserved keyword '{keyword}' appears multiple times"
2237            );
2238        }
2239    }
2240
2241    // CLI flag combination and validation tests
2242
2243    #[test]
2244    fn test_cli_flag_combinations() {
2245        use clap::Parser;
2246
2247        // Test valid flag combinations
2248        let valid_combinations = vec![
2249            vec!["blz", "search", "rust", "--limit", "20"],
2250            vec!["blz", "search", "rust", "--alias", "node", "--limit", "10"],
2251            vec!["blz", "search", "rust", "--all"],
2252            vec!["blz", "add", "test", "https://example.com/llms.txt"],
2253            vec!["blz", "list", "--output", "json"],
2254            vec!["blz", "update", "--all"],
2255            vec!["blz", "remove", "test"],
2256            vec!["blz", "get", "test", "--lines", "1-10"],
2257            vec!["blz", "lookup", "react"],
2258        ];
2259
2260        for combination in valid_combinations {
2261            let result = Cli::try_parse_from(combination.clone());
2262            assert!(
2263                result.is_ok(),
2264                "Valid combination should parse: {combination:?}"
2265            );
2266        }
2267    }
2268
2269    #[test]
2270    #[allow(deprecated)]
2271    fn test_cli_parse_refresh_multiple_aliases() {
2272        use clap::Parser;
2273
2274        let cli = Cli::try_parse_from(vec!["blz", "refresh", "bun", "react"]).unwrap();
2275        match cli.command {
2276            Some(Commands::Refresh { aliases, all, .. }) => {
2277                assert_eq!(aliases, vec!["bun", "react"]);
2278                assert!(!all);
2279            },
2280            other => panic!("Expected refresh command, got {other:?}"),
2281        }
2282    }
2283
2284    #[test]
2285    fn test_cli_refresh_all_conflict_with_aliases() {
2286        use clap::Parser;
2287
2288        let result = Cli::try_parse_from(vec!["blz", "refresh", "bun", "--all"]);
2289        assert!(
2290            result.is_err(),
2291            "Should error when both --all and aliases are provided"
2292        );
2293    }
2294
2295    #[test]
2296    #[allow(deprecated)]
2297    fn test_cli_parse_update_multiple_aliases() {
2298        use clap::Parser;
2299
2300        // Test deprecated 'update' command for backward compatibility
2301        let cli = Cli::try_parse_from(vec!["blz", "update", "bun", "react"]).unwrap();
2302        match cli.command {
2303            Some(Commands::Update { aliases, all, .. }) => {
2304                assert_eq!(aliases, vec!["bun", "react"]);
2305                assert!(!all);
2306            },
2307            other => panic!("Expected update command, got {other:?}"),
2308        }
2309    }
2310
2311    #[test]
2312    #[allow(deprecated)]
2313    fn test_cli_update_all_conflict_with_aliases() {
2314        use clap::Parser;
2315
2316        // Test deprecated 'update' command for backward compatibility
2317        let result = Cli::try_parse_from(vec!["blz", "update", "bun", "--all"]);
2318        assert!(
2319            result.is_err(),
2320            "--all should conflict with explicit aliases"
2321        );
2322    }
2323
2324    #[test]
2325    fn test_preprocess_shorthand_context_flags() {
2326        fn assert_processed(input: &[&str], expected: &[&str]) {
2327            let raw = to_string_vec(input);
2328            let processed = preprocess_args_from(&raw);
2329            assert_eq!(
2330                processed,
2331                to_string_vec(expected),
2332                "unexpected preprocess result for {input:?}"
2333            );
2334        }
2335
2336        assert_processed(
2337            &["blz", "hooks", "--context", "all"],
2338            &["blz", "search", "hooks", "--context", "all"],
2339        );
2340        assert_processed(
2341            &["blz", "hooks", "--context", "5"],
2342            &["blz", "search", "hooks", "--context", "5"],
2343        );
2344        assert_processed(
2345            &["blz", "hooks", "-C5"],
2346            &["blz", "search", "hooks", "-C", "5"],
2347        );
2348        assert_processed(
2349            &["blz", "hooks", "-A3"],
2350            &["blz", "search", "hooks", "-A", "3"],
2351        );
2352        assert_processed(
2353            &["blz", "hooks", "-B2"],
2354            &["blz", "search", "hooks", "-B", "2"],
2355        );
2356        assert_processed(
2357            &["blz", "hooks", "--after-context", "4"],
2358            &["blz", "search", "hooks", "--after-context", "4"],
2359        );
2360        assert_processed(
2361            &["blz", "hooks", "--before-context", "4"],
2362            &["blz", "search", "hooks", "--before-context", "4"],
2363        );
2364        assert_processed(
2365            &["blz", "hooks", "--context", "all", "--source", "ctx"],
2366            &[
2367                "blz",
2368                "search",
2369                "hooks",
2370                "--context",
2371                "all",
2372                "--source",
2373                "ctx",
2374            ],
2375        );
2376    }
2377
2378    #[test]
2379    fn test_cli_invalid_flag_combinations() {
2380        use clap::Parser;
2381
2382        // Test invalid flag combinations that should fail
2383        let invalid_combinations = vec![
2384            // Missing required arguments
2385            vec!["blz", "add", "alias"], // Missing URL
2386            // Note: "blz get alias" is now valid (supports colon syntax like "alias:1-3")
2387            vec!["blz", "search"], // Missing query
2388            vec!["blz", "lookup"], // Missing query
2389            // Invalid flag values
2390            vec!["blz", "search", "rust", "--limit", "-5"], // Negative limit
2391            vec!["blz", "search", "rust", "--page", "-1"],  // Negative page
2392            vec!["blz", "list", "--output", "invalid"],     // Invalid output format
2393
2394                                                            // Note: --all with --limit is actually valid (--all sets limit to 10000)
2395                                                            // Note: update with alias and --all is also valid
2396
2397                                                            // Add actual invalid combinations here as needed
2398        ];
2399
2400        for combination in invalid_combinations {
2401            let result = Cli::try_parse_from(combination.clone());
2402            assert!(
2403                result.is_err(),
2404                "Invalid combination should fail: {combination:?}"
2405            );
2406        }
2407    }
2408
2409    #[test]
2410    fn test_cli_help_generation() {
2411        use clap::Parser;
2412
2413        // Test that help can be generated without errors
2414        let help_commands = vec![
2415            vec!["blz", "--help"],
2416            vec!["blz", "search", "--help"],
2417            vec!["blz", "add", "--help"],
2418            vec!["blz", "list", "--help"],
2419            vec!["blz", "get", "--help"],
2420            vec!["blz", "update", "--help"],
2421            vec!["blz", "remove", "--help"],
2422            vec!["blz", "lookup", "--help"],
2423            vec!["blz", "completions", "--help"],
2424        ];
2425
2426        for help_cmd in help_commands {
2427            let result = Cli::try_parse_from(help_cmd.clone());
2428            // Help commands should fail parsing but with a specific help error
2429            if let Err(error) = result {
2430                assert!(
2431                    error.kind() == clap::error::ErrorKind::DisplayHelp,
2432                    "Help command should display help: {help_cmd:?}"
2433                );
2434            } else {
2435                panic!("Help command should not succeed: {help_cmd:?}");
2436            }
2437        }
2438    }
2439
2440    #[test]
2441    fn test_cli_version_flag() {
2442        use clap::Parser;
2443
2444        let version_commands = vec![vec!["blz", "--version"], vec!["blz", "-V"]];
2445
2446        for version_cmd in version_commands {
2447            let result = Cli::try_parse_from(version_cmd.clone());
2448            // Version commands should fail parsing but with a specific version error
2449            if let Err(error) = result {
2450                assert!(
2451                    error.kind() == clap::error::ErrorKind::DisplayVersion,
2452                    "Version command should display version: {version_cmd:?}"
2453                );
2454            } else {
2455                panic!("Version command should not succeed: {version_cmd:?}");
2456            }
2457        }
2458    }
2459
2460    #[test]
2461    fn test_cli_default_values() {
2462        use clap::Parser;
2463
2464        // Test that default values are set correctly
2465        let cli = Cli::try_parse_from(vec!["blz", "search", "test"]).unwrap();
2466
2467        if let Some(Commands::Search {
2468            limit,
2469            page,
2470            all,
2471            format,
2472            ..
2473        }) = cli.command
2474        {
2475            assert_eq!(
2476                limit, None,
2477                "Default limit should be unset (defaults to 50)"
2478            );
2479            assert_eq!(page, 1, "Default page should be 1");
2480            assert!(!all, "Default all should be false");
2481            // When running tests, stdout is not a terminal, so default is JSON when piped
2482            let expected_format = if is_terminal::IsTerminal::is_terminal(&std::io::stdout()) {
2483                crate::output::OutputFormat::Text
2484            } else {
2485                crate::output::OutputFormat::Json
2486            };
2487            assert_eq!(
2488                format.resolve(false),
2489                expected_format,
2490                "Default format should be JSON when piped, Text when terminal"
2491            );
2492        } else {
2493            panic!("Expected search command");
2494        }
2495    }
2496
2497    #[test]
2498    fn test_cli_flag_validation_edge_cases() {
2499        use clap::Parser;
2500
2501        // Test edge cases for numeric values
2502        let edge_cases = vec![
2503            // Very large values
2504            vec!["blz", "search", "rust", "--limit", "999999"],
2505            vec!["blz", "search", "rust", "--page", "999999"],
2506            // Boundary values
2507            vec!["blz", "search", "rust", "--limit", "1"],
2508            vec!["blz", "search", "rust", "--page", "1"],
2509            // Maximum reasonable values
2510            vec!["blz", "search", "rust", "--limit", "10000"],
2511            vec!["blz", "search", "rust", "--page", "1000"],
2512        ];
2513
2514        for edge_case in edge_cases {
2515            let result = Cli::try_parse_from(edge_case.clone());
2516
2517            // All these should parse successfully (validation happens at runtime)
2518            assert!(result.is_ok(), "Edge case should parse: {edge_case:?}");
2519        }
2520    }
2521
2522    #[test]
2523    fn test_cli_string_argument_validation() {
2524        use clap::Parser;
2525
2526        // Test various string inputs
2527        let string_cases = vec![
2528            // Normal cases
2529            vec!["blz", "search", "normal query"],
2530            vec!["blz", "add", "test-alias", "https://example.com/llms.txt"],
2531            vec!["blz", "lookup", "react"],
2532            // Edge cases
2533            vec!["blz", "search", ""], // Empty query (should be handled at runtime)
2534            vec![
2535                "blz",
2536                "search",
2537                "very-long-query-with-lots-of-words-to-test-limits",
2538            ],
2539            vec!["blz", "add", "alias", "file:///local/path.txt"], // File URL
2540            // Special characters
2541            vec!["blz", "search", "query with spaces"],
2542            vec!["blz", "search", "query-with-dashes"],
2543            vec!["blz", "search", "query_with_underscores"],
2544            vec!["blz", "add", "test", "https://example.com/path?query=value"],
2545        ];
2546
2547        for string_case in string_cases {
2548            let result = Cli::try_parse_from(string_case.clone());
2549
2550            // Most string cases should parse (validation happens at runtime)
2551            assert!(result.is_ok(), "String case should parse: {string_case:?}");
2552        }
2553    }
2554
2555    #[test]
2556    fn test_cli_output_format_validation() {
2557        use clap::Parser;
2558
2559        // Test all valid output formats
2560        let format_options = vec![
2561            ("text", crate::output::OutputFormat::Text),
2562            ("json", crate::output::OutputFormat::Json),
2563            ("jsonl", crate::output::OutputFormat::Jsonl),
2564        ];
2565
2566        for (format_str, expected_format) in &format_options {
2567            let cli = Cli::try_parse_from(vec!["blz", "list", "--format", *format_str]).unwrap();
2568
2569            if let Some(Commands::List { format, .. }) = cli.command {
2570                assert_eq!(
2571                    format.resolve(false),
2572                    *expected_format,
2573                    "Format should match: {format_str}"
2574                );
2575            } else {
2576                panic!("Expected list command");
2577            }
2578        }
2579
2580        // Alias --output should continue to work for compatibility
2581        for (format_str, expected_format) in &format_options {
2582            let cli = Cli::try_parse_from(vec!["blz", "list", "--output", *format_str]).unwrap();
2583
2584            if let Some(Commands::List { format, .. }) = cli.command {
2585                assert_eq!(
2586                    format.resolve(false),
2587                    *expected_format,
2588                    "Alias --output should map to {format_str}"
2589                );
2590            } else {
2591                panic!("Expected list command");
2592            }
2593        }
2594
2595        // Test invalid format value
2596        let result = Cli::try_parse_from(vec!["blz", "list", "--format", "invalid"]);
2597        assert!(result.is_err(), "Invalid output format should fail");
2598    }
2599
2600    fn to_string_vec(items: &[&str]) -> Vec<String> {
2601        items.iter().copied().map(str::to_owned).collect()
2602    }
2603
2604    #[test]
2605    fn preprocess_injects_search_for_shorthand_flags() {
2606        use clap::Parser;
2607
2608        // Use a term that is NOT a subcommand name
2609        let raw = to_string_vec(&["blz", "searchterm", "-s", "react"]);
2610        let processed = preprocess_args_from(&raw);
2611
2612        let expected = to_string_vec(&["blz", "search", "searchterm", "-s", "react"]);
2613        assert_eq!(processed, expected);
2614
2615        let cli = Cli::try_parse_from(processed).unwrap();
2616        match cli.command {
2617            Some(Commands::Search { sources, .. }) => {
2618                assert_eq!(sources, vec!["react"]);
2619            },
2620            _ => panic!("expected search command"),
2621        }
2622    }
2623
2624    #[test]
2625    fn preprocess_injects_search_for_next_flag() {
2626        let raw = to_string_vec(&["blz", "--next"]);
2627        let processed = preprocess_args_from(&raw);
2628        let expected = to_string_vec(&["blz", "search", "--next"]);
2629        assert_eq!(processed, expected);
2630    }
2631
2632    #[test]
2633    fn preprocess_preserves_global_flags_order() {
2634        // Use a term that is NOT a subcommand name
2635        let raw = to_string_vec(&["blz", "--quiet", "searchterm", "-s", "docs"]);
2636        let processed = preprocess_args_from(&raw);
2637        let expected = to_string_vec(&["blz", "--quiet", "search", "searchterm", "-s", "docs"]);
2638        assert_eq!(processed, expected);
2639    }
2640
2641    #[test]
2642    fn preprocess_converts_json_aliases() {
2643        use clap::Parser;
2644
2645        // Use a term that is NOT a subcommand name
2646        let raw = to_string_vec(&["blz", "searchterm", "--json"]);
2647        let processed = preprocess_args_from(&raw);
2648        let expected = to_string_vec(&["blz", "search", "searchterm", "--format", "json"]);
2649        assert_eq!(processed, expected);
2650
2651        let cli = Cli::try_parse_from(processed).unwrap();
2652        match cli.command {
2653            Some(Commands::Search { format, .. }) => {
2654                assert_eq!(format.resolve(false), crate::output::OutputFormat::Json);
2655            },
2656            _ => panic!("expected search command"),
2657        }
2658    }
2659
2660    #[test]
2661    fn preprocess_handles_list_subcommand_without_injection() {
2662        use clap::Parser;
2663
2664        let raw = to_string_vec(&["blz", "list", "--jsonl"]);
2665        let processed = preprocess_args_from(&raw);
2666        assert_eq!(processed, raw);
2667
2668        let cli = Cli::try_parse_from(raw).unwrap();
2669        match cli.command {
2670            Some(Commands::List { format, .. }) => {
2671                assert_eq!(format.resolve(false), crate::output::OutputFormat::Jsonl);
2672            },
2673            _ => panic!("expected list command"),
2674        }
2675    }
2676
2677    #[test]
2678    fn preprocess_respects_sentinel() {
2679        let raw = to_string_vec(&["blz", "query", "--", "-s", "react"]);
2680        let processed = preprocess_args_from(&raw);
2681        assert_eq!(processed, raw);
2682    }
2683
2684    #[test]
2685    fn preprocess_does_not_inject_hidden_subcommands() {
2686        let raw = to_string_vec(&["blz", "toc", "e2e", "-f", "json"]);
2687        let processed = preprocess_args_from(&raw);
2688        assert_eq!(processed, raw);
2689    }
2690
2691    #[test]
2692    fn preprocess_retains_hidden_subcommand_with_search_flags() {
2693        let raw = to_string_vec(&["blz", "toc", "e2e", "--limit", "5", "--json"]);
2694        let processed = preprocess_args_from(&raw);
2695        // Should preserve --json for explicit subcommands (even hidden ones)
2696        assert_eq!(
2697            processed, raw,
2698            "hidden subcommands must not trigger shorthand injection or format conversion"
2699        );
2700    }
2701
2702    #[test]
2703    #[allow(deprecated)]
2704    fn anchors_alias_still_parses_to_toc() {
2705        use clap::Parser;
2706
2707        let raw = to_string_vec(&["blz", "anchors", "react"]);
2708        let cli = Cli::try_parse_from(raw).expect("anchors alias should parse");
2709        match cli.command {
2710            Some(Commands::Toc { alias, .. }) => assert_eq!(alias, Some("react".to_string())),
2711            other => panic!("expected toc command, got {other:?}"),
2712        }
2713    }
2714
2715    #[test]
2716    fn preprocess_handles_context_flags() {
2717        use clap::Parser;
2718
2719        // Test --context flag (use term that is NOT a subcommand name)
2720        let raw = to_string_vec(&["blz", "searchterm", "--context", "all"]);
2721        let processed = preprocess_args_from(&raw);
2722        let expected = to_string_vec(&["blz", "search", "searchterm", "--context", "all"]);
2723        assert_eq!(processed, expected);
2724
2725        let cli = Cli::try_parse_from(processed).unwrap();
2726        match cli.command {
2727            Some(Commands::Search { context, .. }) => {
2728                assert!(context.is_some());
2729            },
2730            _ => panic!("expected search command"),
2731        }
2732    }
2733
2734    #[test]
2735    fn preprocess_handles_short_context_flags() {
2736        use clap::Parser;
2737
2738        // Test -C flag with attached value
2739        let raw = to_string_vec(&["blz", "hooks", "-C5"]);
2740        let processed = preprocess_args_from(&raw);
2741        let expected = to_string_vec(&["blz", "search", "hooks", "-C", "5"]);
2742        assert_eq!(processed, expected);
2743
2744        let cli = Cli::try_parse_from(processed).unwrap();
2745        match cli.command {
2746            Some(Commands::Search { context, .. }) => {
2747                assert!(context.is_some());
2748            },
2749            _ => panic!("expected search command"),
2750        }
2751    }
2752
2753    #[test]
2754    fn preprocess_handles_after_context_flag() {
2755        use clap::Parser;
2756
2757        // Test -A flag
2758        let raw = to_string_vec(&["blz", "api", "-A3"]);
2759        let processed = preprocess_args_from(&raw);
2760        let expected = to_string_vec(&["blz", "search", "api", "-A", "3"]);
2761        assert_eq!(processed, expected);
2762
2763        let cli = Cli::try_parse_from(processed).unwrap();
2764        match cli.command {
2765            Some(Commands::Search { after_context, .. }) => {
2766                assert_eq!(after_context, Some(3));
2767            },
2768            _ => panic!("expected search command"),
2769        }
2770    }
2771
2772    #[test]
2773    fn preprocess_handles_before_context_flag() {
2774        // Test -B flag
2775        let raw = to_string_vec(&["blz", "documentation", "-B2"]);
2776        let processed = preprocess_args_from(&raw);
2777        let expected = to_string_vec(&["blz", "search", "documentation", "-B", "2"]);
2778        assert_eq!(processed, expected);
2779    }
2780
2781    #[test]
2782    fn preprocess_handles_deprecated_context_flag() {
2783        // Test deprecated -c flag
2784        let raw = to_string_vec(&["blz", "example", "-c5"]);
2785        let processed = preprocess_args_from(&raw);
2786        let expected = to_string_vec(&["blz", "search", "example", "-c", "5"]);
2787        assert_eq!(processed, expected);
2788    }
2789
2790    #[test]
2791    fn preprocess_handles_block_flag() {
2792        use clap::Parser;
2793
2794        let raw = to_string_vec(&["blz", "guide", "--block"]);
2795        let processed = preprocess_args_from(&raw);
2796        let expected = to_string_vec(&["blz", "search", "guide", "--block"]);
2797        assert_eq!(processed, expected);
2798
2799        let cli = Cli::try_parse_from(processed).unwrap();
2800        match cli.command {
2801            Some(Commands::Search { block, .. }) => {
2802                assert!(block);
2803            },
2804            _ => panic!("expected search command"),
2805        }
2806    }
2807
2808    #[test]
2809    fn preprocess_handles_max_lines_flag() {
2810        let raw = to_string_vec(&["blz", "example", "--max-lines", "50"]);
2811        let processed = preprocess_args_from(&raw);
2812        let expected = to_string_vec(&["blz", "search", "example", "--max-lines", "50"]);
2813        assert_eq!(processed, expected);
2814    }
2815
2816    #[test]
2817    fn preprocess_handles_max_chars_flag() {
2818        let raw = to_string_vec(&["blz", "example", "--max-chars", "300"]);
2819        let processed = preprocess_args_from(&raw);
2820        let expected = to_string_vec(&["blz", "search", "example", "--max-chars", "300"]);
2821        assert_eq!(processed, expected);
2822    }
2823
2824    #[test]
2825    fn preprocess_handles_no_history_flag() {
2826        // Use a term that is NOT a subcommand name
2827        let raw = to_string_vec(&["blz", "searchterm", "--no-history"]);
2828        let processed = preprocess_args_from(&raw);
2829        let expected = to_string_vec(&["blz", "search", "searchterm", "--no-history"]);
2830        assert_eq!(processed, expected);
2831    }
2832
2833    #[test]
2834    fn preprocess_handles_copy_flag() {
2835        // Use a term that is NOT a subcommand name
2836        let raw = to_string_vec(&["blz", "searchterm", "--copy"]);
2837        let processed = preprocess_args_from(&raw);
2838        let expected = to_string_vec(&["blz", "search", "searchterm", "--copy"]);
2839        assert_eq!(processed, expected);
2840    }
2841
2842    #[test]
2843    fn preprocess_handles_multiple_context_flags() {
2844        use clap::Parser;
2845
2846        // Test combination with other flags
2847        let raw = to_string_vec(&[
2848            "blz",
2849            "test",
2850            "--context",
2851            "all",
2852            "-s",
2853            "react",
2854            "--limit",
2855            "10",
2856        ]);
2857        let processed = preprocess_args_from(&raw);
2858        let expected = to_string_vec(&[
2859            "blz",
2860            "search",
2861            "test",
2862            "--context",
2863            "all",
2864            "-s",
2865            "react",
2866            "--limit",
2867            "10",
2868        ]);
2869        assert_eq!(processed, expected);
2870
2871        let cli = Cli::try_parse_from(processed).unwrap();
2872        match cli.command {
2873            Some(Commands::Search {
2874                context,
2875                sources,
2876                limit,
2877                ..
2878            }) => {
2879                assert!(context.is_some());
2880                assert_eq!(sources, vec!["react"]);
2881                assert_eq!(limit, Some(10));
2882            },
2883            _ => panic!("expected search command"),
2884        }
2885    }
2886
2887    #[test]
2888    fn preprocess_handles_context_with_json_flag() {
2889        use clap::Parser;
2890
2891        // Regression test for the original bug report
2892        let raw = to_string_vec(&["blz", "test runner", "--context", "all", "--json"]);
2893        let processed = preprocess_args_from(&raw);
2894        let expected = to_string_vec(&[
2895            "blz",
2896            "search",
2897            "test runner",
2898            "--context",
2899            "all",
2900            "--format",
2901            "json",
2902        ]);
2903        assert_eq!(processed, expected);
2904
2905        let cli = Cli::try_parse_from(processed).unwrap();
2906        match cli.command {
2907            Some(Commands::Search {
2908                context, format, ..
2909            }) => {
2910                assert!(context.is_some());
2911                assert_eq!(format.resolve(false), crate::output::OutputFormat::Json);
2912            },
2913            _ => panic!("expected search command"),
2914        }
2915    }
2916
2917    #[test]
2918    fn preprocess_handles_combined_context_flags() {
2919        // Test -A and -B together
2920        let raw = to_string_vec(&["blz", "documentation", "-A3", "-B2"]);
2921        let processed = preprocess_args_from(&raw);
2922        let expected = to_string_vec(&["blz", "search", "documentation", "-A", "3", "-B", "2"]);
2923        assert_eq!(processed, expected);
2924    }
2925
2926    #[test]
2927    fn known_subcommands_cover_clap_definitions() {
2928        use clap::CommandFactory;
2929
2930        let command = Cli::command();
2931        for sub in command.get_subcommands() {
2932            let name = sub.get_name();
2933            assert!(
2934                is_known_subcommand(name),
2935                "expected known subcommand to include {name}"
2936            );
2937
2938            for alias in sub.get_all_aliases() {
2939                assert!(
2940                    is_known_subcommand(alias),
2941                    "expected alias {alias} to be recognized"
2942                );
2943            }
2944        }
2945    }
2946
2947    #[test]
2948    fn test_cli_boolean_flags() {
2949        use clap::Parser;
2950
2951        // Test boolean flags
2952        let bool_flag_cases = vec![
2953            // Verbose flag
2954            (
2955                vec!["blz", "--verbose", "search", "test"],
2956                true,
2957                false,
2958                false,
2959            ),
2960            (vec!["blz", "-v", "search", "test"], true, false, false),
2961            // Debug flag
2962            (vec!["blz", "--debug", "search", "test"], false, true, false),
2963            // Profile flag
2964            (
2965                vec!["blz", "--profile", "search", "test"],
2966                false,
2967                false,
2968                true,
2969            ),
2970            // Multiple flags
2971            (
2972                vec!["blz", "--verbose", "--debug", "--profile", "search", "test"],
2973                true,
2974                true,
2975                true,
2976            ),
2977            (
2978                vec!["blz", "-v", "--debug", "--profile", "search", "test"],
2979                true,
2980                true,
2981                true,
2982            ),
2983        ];
2984
2985        for (args, expected_verbose, expected_debug, expected_profile) in bool_flag_cases {
2986            let cli = Cli::try_parse_from(args.clone()).unwrap();
2987
2988            assert_eq!(
2989                cli.verbose, expected_verbose,
2990                "Verbose flag mismatch for: {args:?}"
2991            );
2992            assert_eq!(
2993                cli.debug, expected_debug,
2994                "Debug flag mismatch for: {args:?}"
2995            );
2996            assert_eq!(
2997                cli.profile, expected_profile,
2998                "Profile flag mismatch for: {args:?}"
2999            );
3000        }
3001    }
3002
3003    #[test]
3004    fn test_cli_subcommand_specific_flags() {
3005        use clap::Parser;
3006
3007        // Test search-specific flags
3008        let cli = Cli::try_parse_from(vec![
3009            "blz", "search", "rust", "--alias", "node", "--limit", "20", "--page", "2", "--top",
3010            "10", "--format", "json",
3011        ])
3012        .unwrap();
3013
3014        if let Some(Commands::Search {
3015            sources,
3016            limit,
3017            page,
3018            top,
3019            format,
3020            ..
3021        }) = cli.command
3022        {
3023            assert_eq!(sources, vec!["node"]);
3024            assert_eq!(limit, Some(20));
3025            assert_eq!(page, 2);
3026            assert!(top.is_some());
3027            assert_eq!(format.resolve(false), crate::output::OutputFormat::Json);
3028        } else {
3029            panic!("Expected search command");
3030        }
3031
3032        // Test add-specific flags
3033        let cli = Cli::try_parse_from(vec![
3034            "blz",
3035            "add",
3036            "test",
3037            "https://example.com/llms.txt",
3038            "--yes",
3039        ])
3040        .unwrap();
3041
3042        if let Some(Commands::Add(args)) = cli.command {
3043            assert_eq!(args.alias.as_deref(), Some("test"));
3044            assert_eq!(args.url.as_deref(), Some("https://example.com/llms.txt"));
3045            assert!(args.aliases.is_empty());
3046            assert!(args.tags.is_empty());
3047            assert!(args.name.is_none());
3048            assert!(args.description.is_none());
3049            assert!(args.category.is_none());
3050            assert!(args.yes);
3051            assert!(!args.dry_run);
3052            assert!(args.manifest.is_none());
3053        } else {
3054            panic!("Expected add command");
3055        }
3056
3057        // Test get-specific flags
3058        let cli = Cli::try_parse_from(vec![
3059            "blz",
3060            "get",
3061            "test",
3062            "--lines",
3063            "1-10",
3064            "--context",
3065            "5",
3066        ])
3067        .unwrap();
3068
3069        if let Some(Commands::Get {
3070            targets,
3071            lines,
3072            source,
3073            context,
3074            block,
3075            max_lines,
3076            format,
3077            copy: _,
3078            ..
3079        }) = cli.command
3080        {
3081            assert_eq!(targets, vec!["test".to_string()]);
3082            assert_eq!(lines, Some("1-10".to_string()));
3083            assert!(source.is_none());
3084            assert_eq!(context, Some(crate::cli::ContextMode::Symmetric(5)));
3085            assert!(!block);
3086            assert_eq!(max_lines, None);
3087            let _ = format; // ignore
3088        } else {
3089            panic!("Expected get command");
3090        }
3091    }
3092
3093    #[test]
3094    fn test_cli_special_argument_parsing() {
3095        use clap::Parser;
3096
3097        // Test line range parsing edge cases
3098        let line_range_cases = vec![
3099            "1",
3100            "1-10",
3101            "1:10",
3102            "1+5",
3103            "10,20,30",
3104            "1-5,10-15,20+5",
3105            "100:200",
3106        ];
3107
3108        for line_range in line_range_cases {
3109            let result = Cli::try_parse_from(vec!["blz", "get", "test", "--lines", line_range]);
3110            assert!(result.is_ok(), "Line range should parse: {line_range}");
3111        }
3112
3113        // Test URL parsing for add command
3114        let url_cases = vec![
3115            "https://example.com/llms.txt",
3116            "http://localhost:3000/llms.txt",
3117            "https://api.example.com/v1/docs/llms.txt",
3118            "https://example.com/llms.txt?version=1",
3119            "https://raw.githubusercontent.com/user/repo/main/llms.txt",
3120        ];
3121
3122        for url in url_cases {
3123            let result = Cli::try_parse_from(vec!["blz", "add", "test", url]);
3124            assert!(result.is_ok(), "URL should parse: {url}");
3125        }
3126    }
3127
3128    #[test]
3129    fn test_cli_error_messages() {
3130        use clap::Parser;
3131
3132        // Test that error messages are informative
3133        let error_cases = vec![
3134            // Missing required arguments
3135            (vec!["blz", "add"], "missing"),
3136            (vec!["blz", "search"], "required"),
3137            // Note: "blz get alias" is now valid (supports colon syntax like "alias:1-3")
3138            // Invalid values
3139            (vec!["blz", "list", "--format", "invalid"], "invalid"),
3140        ];
3141
3142        for (args, expected_error_content) in error_cases {
3143            let result = Cli::try_parse_from(args.clone());
3144
3145            assert!(result.is_err(), "Should error for: {args:?}");
3146
3147            let error_msg = format!("{:?}", result.unwrap_err()).to_lowercase();
3148            assert!(
3149                error_msg.contains(expected_error_content),
3150                "Error message should contain '{expected_error_content}' for args {args:?}, got: {error_msg}"
3151            );
3152        }
3153    }
3154
3155    #[test]
3156    fn test_cli_argument_order_independence() {
3157        use clap::Parser;
3158
3159        // Test that global flags can appear in different positions
3160        let equivalent_commands = vec![
3161            vec![
3162                vec!["blz", "--verbose", "search", "rust"],
3163                vec!["blz", "search", "--verbose", "rust"],
3164            ],
3165            vec![
3166                vec!["blz", "--debug", "--profile", "search", "rust"],
3167                vec!["blz", "search", "rust", "--debug", "--profile"],
3168                vec!["blz", "--debug", "search", "--profile", "rust"],
3169            ],
3170        ];
3171
3172        for command_group in equivalent_commands {
3173            let mut parsed_commands = Vec::new();
3174
3175            for args in &command_group {
3176                let result = Cli::try_parse_from(args.clone());
3177                assert!(result.is_ok(), "Should parse: {args:?}");
3178                parsed_commands.push(result.unwrap());
3179            }
3180
3181            // All commands in the group should parse to equivalent structures
3182            let first = &parsed_commands[0];
3183            for other in &parsed_commands[1..] {
3184                assert_eq!(first.verbose, other.verbose, "Verbose flags should match");
3185                assert_eq!(first.debug, other.debug, "Debug flags should match");
3186                assert_eq!(first.profile, other.profile, "Profile flags should match");
3187            }
3188        }
3189    }
3190
3191    // Shell completion generation and accuracy tests
3192
3193    #[test]
3194    fn test_completion_generation_for_all_shells() {
3195        use clap_complete::Shell;
3196
3197        // Test that completions can be generated for all supported shells without panicking
3198        let shells = vec![
3199            Shell::Bash,
3200            Shell::Zsh,
3201            Shell::Fish,
3202            Shell::PowerShell,
3203            Shell::Elvish,
3204        ];
3205
3206        for shell in shells {
3207            // Should not panic - this is the main test
3208            let result = std::panic::catch_unwind(|| {
3209                crate::commands::generate(shell);
3210            });
3211
3212            assert!(
3213                result.is_ok(),
3214                "Completion generation should not panic for {shell:?}"
3215            );
3216        }
3217    }
3218
3219    #[test]
3220    fn test_completion_cli_structure_contains_all_subcommands() {
3221        use crate::cli::Cli;
3222        use clap::CommandFactory;
3223
3224        // Test that our CLI structure has all expected subcommands (which completions will include)
3225        let cmd = Cli::command();
3226
3227        let subcommands: Vec<&str> = cmd.get_subcommands().map(clap::Command::get_name).collect();
3228
3229        // Verify all main subcommands are present in CLI structure
3230        let expected_commands = vec![
3231            "search",
3232            "add",
3233            "list",
3234            "get",
3235            "refresh",
3236            "update",
3237            "remove",
3238            "lookup",
3239            "diff",
3240            "completions",
3241        ];
3242
3243        for expected_command in expected_commands {
3244            assert!(
3245                subcommands.contains(&expected_command),
3246                "CLI should have '{expected_command}' subcommand for completions"
3247            );
3248        }
3249
3250        // Verify command aliases are configured in CLI structure
3251        let list_cmd = cmd
3252            .get_subcommands()
3253            .find(|sub| sub.get_name() == "list")
3254            .expect("Should have list command");
3255
3256        let aliases: Vec<&str> = list_cmd.get_all_aliases().collect();
3257        assert!(
3258            aliases.contains(&"sources"),
3259            "List command should have 'sources' alias"
3260        );
3261
3262        // `rm` is now its own command (not an alias of `remove`)
3263        let rm_cmd = cmd
3264            .get_subcommands()
3265            .find(|sub| sub.get_name() == "rm")
3266            .expect("Should have rm command");
3267        assert_eq!(rm_cmd.get_name(), "rm");
3268
3269        // `remove` is deprecated but still exists
3270        let remove_cmd = cmd.get_subcommands().find(|sub| sub.get_name() == "remove");
3271        assert!(
3272            remove_cmd.is_some(),
3273            "Remove command should still exist (deprecated)"
3274        );
3275    }
3276
3277    #[test]
3278    fn test_completion_cli_structure_contains_global_flags() {
3279        use crate::cli::Cli;
3280        use clap::CommandFactory;
3281
3282        // Test that our CLI structure has all expected global flags (which completions will include)
3283        let cmd = Cli::command();
3284
3285        let global_args: Vec<&str> = cmd
3286            .get_arguments()
3287            .filter(|arg| arg.is_global_set())
3288            .map(|arg| arg.get_id().as_str())
3289            .collect();
3290
3291        // Verify global flags are present in CLI structure
3292        let expected_global_flags = vec!["verbose", "debug", "profile"];
3293
3294        for expected_flag in expected_global_flags {
3295            assert!(
3296                global_args.contains(&expected_flag),
3297                "CLI should have global flag '{expected_flag}' for completions"
3298            );
3299        }
3300
3301        // Verify verbose flag properties
3302        let verbose_arg = cmd
3303            .get_arguments()
3304            .find(|arg| arg.get_id().as_str() == "verbose")
3305            .expect("Should have verbose argument");
3306
3307        assert!(
3308            verbose_arg.get_long().is_some(),
3309            "Verbose should have long form --verbose"
3310        );
3311        assert_eq!(
3312            verbose_arg.get_long(),
3313            Some("verbose"),
3314            "Verbose long form should be --verbose"
3315        );
3316        assert!(verbose_arg.is_global_set(), "Verbose should be global");
3317    }
3318
3319    #[test]
3320    fn test_completion_cli_structure_contains_subcommand_flags() {
3321        use crate::cli::Cli;
3322        use clap::CommandFactory;
3323
3324        let cmd = Cli::command();
3325
3326        // Check search command flags
3327        let search_cmd = cmd
3328            .get_subcommands()
3329            .find(|sub| sub.get_name() == "search")
3330            .expect("Should have search command");
3331
3332        let search_args: Vec<&str> = search_cmd
3333            .get_arguments()
3334            .map(|arg| arg.get_id().as_str())
3335            .collect();
3336
3337        let expected_search_flags = vec![
3338            "sources",
3339            "limit",
3340            "all",
3341            "page",
3342            "top",
3343            "format",
3344            "show",
3345            "no_summary",
3346        ];
3347        for expected_flag in expected_search_flags {
3348            assert!(
3349                search_args.contains(&expected_flag),
3350                "Search command should have '{expected_flag}' flag for completions"
3351            );
3352        }
3353
3354        // Check add command flags
3355        let add_cmd = cmd
3356            .get_subcommands()
3357            .find(|sub| sub.get_name() == "add")
3358            .expect("Should have add command");
3359
3360        let add_args: Vec<&str> = add_cmd
3361            .get_arguments()
3362            .map(|arg| arg.get_id().as_str())
3363            .collect();
3364
3365        assert!(
3366            add_args.contains(&"yes"),
3367            "Add command should have 'yes' flag"
3368        );
3369
3370        // Check get command flags
3371        let get_cmd = cmd
3372            .get_subcommands()
3373            .find(|sub| sub.get_name() == "get")
3374            .expect("Should have get command");
3375
3376        let get_args: Vec<&str> = get_cmd
3377            .get_arguments()
3378            .map(|arg| arg.get_id().as_str())
3379            .collect();
3380
3381        assert!(
3382            get_args.contains(&"lines"),
3383            "Get command should have 'lines' flag"
3384        );
3385        assert!(
3386            get_args.contains(&"context"),
3387            "Get command should have 'context' flag"
3388        );
3389
3390        // Check that output argument has value_enum (which provides completion values)
3391        let format_arg = search_cmd
3392            .get_arguments()
3393            .find(|arg| arg.get_id().as_str() == "format")
3394            .expect("Search should have format argument");
3395
3396        assert!(
3397            !format_arg.get_possible_values().is_empty(),
3398            "Format argument should have possible values for completion"
3399        );
3400    }
3401
3402    #[test]
3403    fn test_completion_generation_consistency() {
3404        use clap_complete::Shell;
3405
3406        // Generate completions multiple times to ensure consistency (no panics)
3407        let shells_to_test = vec![Shell::Bash, Shell::Zsh, Shell::Fish];
3408
3409        for shell in shells_to_test {
3410            // Should not panic on multiple generations
3411            for _ in 0..3 {
3412                let result = std::panic::catch_unwind(|| {
3413                    crate::commands::generate(shell);
3414                });
3415                assert!(
3416                    result.is_ok(),
3417                    "Completion generation should be consistent for {shell:?}"
3418                );
3419            }
3420        }
3421    }
3422
3423    #[test]
3424    fn test_completion_command_parsing() {
3425        use clap::Parser;
3426
3427        // Test that the completions command parses correctly for all shells
3428        let shell_completions = vec![
3429            vec!["blz", "completions", "bash"],
3430            vec!["blz", "completions", "zsh"],
3431            vec!["blz", "completions", "fish"],
3432            vec!["blz", "completions", "powershell"],
3433            vec!["blz", "completions", "elvish"],
3434        ];
3435
3436        for args in shell_completions {
3437            let result = Cli::try_parse_from(args.clone());
3438            assert!(result.is_ok(), "Completions command should parse: {args:?}");
3439
3440            if let Ok(cli) = result {
3441                match cli.command {
3442                    Some(Commands::Completions { shell: _, .. }) => {
3443                        // Expected - completions command parsed successfully
3444                    },
3445                    other => {
3446                        panic!("Expected Completions command, got: {other:?} for args: {args:?}");
3447                    },
3448                }
3449            }
3450        }
3451    }
3452
3453    #[test]
3454    fn test_completion_invalid_shell_handling() {
3455        use clap::Parser;
3456
3457        // Test that invalid shell names are rejected
3458        let invalid_shells = vec![
3459            vec!["blz", "completions", "invalid"],
3460            vec!["blz", "completions", "cmd"],
3461            vec!["blz", "completions", ""],
3462            vec!["blz", "completions", "bash_typo"],
3463            vec!["blz", "completions", "ZSH"], // Wrong case
3464        ];
3465
3466        for args in invalid_shells {
3467            let result = Cli::try_parse_from(args.clone());
3468            assert!(
3469                result.is_err(),
3470                "Invalid shell should be rejected: {args:?}"
3471            );
3472        }
3473    }
3474
3475    #[test]
3476    fn test_completion_help_generation() {
3477        use clap::Parser;
3478
3479        // Test that help for completions command works
3480        let help_commands = vec![
3481            vec!["blz", "completions", "--help"],
3482            vec!["blz", "completions", "-h"],
3483        ];
3484
3485        for help_cmd in help_commands {
3486            let result = Cli::try_parse_from(help_cmd.clone());
3487
3488            if let Err(error) = result {
3489                assert_eq!(
3490                    error.kind(),
3491                    clap::error::ErrorKind::DisplayHelp,
3492                    "Completions help should display help: {help_cmd:?}"
3493                );
3494
3495                let help_text = error.to_string();
3496                assert!(
3497                    help_text.contains("completions"),
3498                    "Help text should mention completions"
3499                );
3500                assert!(
3501                    help_text.contains("shell") || help_text.contains("Shell"),
3502                    "Help text should mention shell parameter"
3503                );
3504            } else {
3505                panic!("Help command should not succeed: {help_cmd:?}");
3506            }
3507        }
3508    }
3509
3510    #[test]
3511    fn test_completion_integration_with_clap() {
3512        use crate::cli::Cli;
3513        use clap::CommandFactory;
3514
3515        // Test that our CLI structure is compatible with clap_complete
3516        let cmd = Cli::command();
3517
3518        // Verify basic command structure that completion depends on
3519        assert_eq!(cmd.get_name(), "blz", "Command name should be 'blz'");
3520
3521        // Verify subcommands are properly configured
3522        let subcommands: Vec<&str> = cmd.get_subcommands().map(clap::Command::get_name).collect();
3523
3524        let expected_subcommands = vec![
3525            "completions",
3526            "add",
3527            "lookup",
3528            "search",
3529            "get",
3530            "list",
3531            "refresh",
3532            "update",
3533            "remove",
3534            "diff",
3535        ];
3536
3537        for expected in expected_subcommands {
3538            assert!(
3539                subcommands.contains(&expected),
3540                "Command should have subcommand '{expected}', found: {subcommands:?}"
3541            );
3542        }
3543
3544        // Verify completions subcommand has proper shell argument
3545        let completions_cmd = cmd
3546            .get_subcommands()
3547            .find(|sub| sub.get_name() == "completions")
3548            .expect("Should have completions subcommand");
3549
3550        let shell_arg = completions_cmd
3551            .get_arguments()
3552            .find(|arg| arg.get_id() == "shell")
3553            .expect("Completions should have shell argument");
3554
3555        assert!(
3556            shell_arg.is_positional(),
3557            "Shell argument should be positional"
3558        );
3559    }
3560
3561    #[test]
3562    fn test_multi_source_search_parsing() {
3563        use clap::Parser;
3564
3565        // Test comma-separated sources
3566        let cli = Cli::try_parse_from(vec![
3567            "blz",
3568            "search",
3569            "hooks",
3570            "--source",
3571            "react,vue,svelte",
3572        ])
3573        .unwrap();
3574
3575        if let Some(Commands::Search { sources, .. }) = cli.command {
3576            assert_eq!(sources, vec!["react", "vue", "svelte"]);
3577        } else {
3578            panic!("Expected search command");
3579        }
3580    }
3581
3582    #[test]
3583    fn test_single_source_search_parsing() {
3584        use clap::Parser;
3585
3586        // Test single source (backward compatibility)
3587        let cli = Cli::try_parse_from(vec!["blz", "search", "hooks", "--source", "react"]).unwrap();
3588
3589        if let Some(Commands::Search { sources, .. }) = cli.command {
3590            assert_eq!(sources, vec!["react"]);
3591        } else {
3592            panic!("Expected search command");
3593        }
3594    }
3595
3596    #[test]
3597    fn test_no_source_search_parsing() {
3598        use clap::Parser;
3599
3600        // Test no source (searches all)
3601        let cli = Cli::try_parse_from(vec!["blz", "search", "hooks"]).unwrap();
3602
3603        if let Some(Commands::Search { sources, .. }) = cli.command {
3604            assert!(sources.is_empty());
3605        } else {
3606            panic!("Expected search command");
3607        }
3608    }
3609
3610    #[test]
3611    fn test_multi_source_shorthand_parsing() {
3612        use clap::Parser;
3613
3614        // Test with -s shorthand
3615        let cli = Cli::try_parse_from(vec!["blz", "search", "api", "-s", "bun,node,deno"]).unwrap();
3616
3617        if let Some(Commands::Search { sources, .. }) = cli.command {
3618            assert_eq!(sources, vec!["bun", "node", "deno"]);
3619        } else {
3620            panic!("Expected search command");
3621        }
3622    }
3623
3624    #[test]
3625    fn test_get_command_with_source_flag() {
3626        use clap::Parser;
3627
3628        let cli = Cli::try_parse_from(vec![
3629            "blz", "get", "meta", "--lines", "1-3", "--source", "bun",
3630        ])
3631        .unwrap();
3632
3633        if let Some(Commands::Get {
3634            targets,
3635            source,
3636            lines,
3637            ..
3638        }) = cli.command
3639        {
3640            assert_eq!(targets, vec!["meta".to_string()]);
3641            assert_eq!(source.as_deref(), Some("bun"));
3642            assert_eq!(lines.as_deref(), Some("1-3"));
3643        } else {
3644            panic!("Expected get command");
3645        }
3646    }
3647
3648    #[test]
3649    fn test_get_command_with_source_shorthand() {
3650        use clap::Parser;
3651
3652        let cli = Cli::try_parse_from(vec!["blz", "get", "meta:4-6", "-s", "canonical"]).unwrap();
3653
3654        if let Some(Commands::Get {
3655            targets, source, ..
3656        }) = cli.command
3657        {
3658            assert_eq!(targets, vec!["meta:4-6".to_string()]);
3659            assert_eq!(source.as_deref(), Some("canonical"));
3660        } else {
3661            panic!("Expected get command");
3662        }
3663    }
3664
3665    #[test]
3666    fn preprocess_preserves_following_flags_for_optional_context() {
3667        use crate::output::OutputFormat;
3668        use clap::Parser;
3669
3670        // Test that context flags without values don't consume following flags
3671        let raw = to_string_vec(&["blz", "foo", "--context", "--json"]);
3672        let processed = preprocess_args_from(&raw);
3673        let expected = to_string_vec(&["blz", "search", "foo", "--context", "--format", "json"]);
3674        assert_eq!(processed, expected);
3675
3676        // Verify clap can parse it correctly
3677        let cli = Cli::try_parse_from(processed).unwrap();
3678        match cli.command {
3679            Some(Commands::Search {
3680                context, format, ..
3681            }) => {
3682                assert!(context.is_some()); // Should get default value
3683                assert_eq!(format.format, Some(OutputFormat::Json));
3684            },
3685            _ => panic!("expected search command"),
3686        }
3687    }
3688
3689    #[test]
3690    fn preprocess_preserves_following_flags_for_short_context() {
3691        use clap::Parser;
3692
3693        // Test that -C without value doesn't consume following flags
3694        let raw = to_string_vec(&["blz", "hooks", "-C", "--source", "react"]);
3695        let processed = preprocess_args_from(&raw);
3696        let expected = to_string_vec(&["blz", "search", "hooks", "-C", "--source", "react"]);
3697        assert_eq!(processed, expected);
3698
3699        // Verify clap can parse it correctly
3700        let cli = Cli::try_parse_from(processed).unwrap();
3701        match cli.command {
3702            Some(Commands::Search {
3703                context, sources, ..
3704            }) => {
3705                assert!(context.is_some()); // Should get default value
3706                assert_eq!(sources, vec!["react"]);
3707            },
3708            _ => panic!("expected search command"),
3709        }
3710    }
3711
3712    #[test]
3713    fn preprocess_preserves_following_flags_for_after_context() {
3714        use clap::Parser;
3715
3716        // Test that -A without value doesn't consume following flags
3717        let raw = to_string_vec(&["blz", "api", "-A", "--limit", "5"]);
3718        let processed = preprocess_args_from(&raw);
3719        let expected = to_string_vec(&["blz", "search", "api", "-A", "--limit", "5"]);
3720        assert_eq!(processed, expected);
3721
3722        // Verify clap can parse it correctly
3723        let cli = Cli::try_parse_from(processed).unwrap();
3724        match cli.command {
3725            Some(Commands::Search {
3726                after_context,
3727                limit,
3728                ..
3729            }) => {
3730                assert!(after_context.is_some()); // Should get default value
3731                assert_eq!(limit, Some(5));
3732            },
3733            _ => panic!("expected search command"),
3734        }
3735    }
3736}