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