1use 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
36fn 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)] fn 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 } 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 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 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 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 result.push(raw[idx + 1].clone());
186 idx += 2;
187 } else {
188 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
240fn 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#[allow(clippy::too_many_lines)] fn 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 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 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 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 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
397pub async fn run() -> Result<()> {
399 std::panic::set_hook(Box::new(|info| {
401 let msg = info.to_string();
402 if msg.contains("Broken pipe") || msg.contains("broken pipe") {
403 std::process::exit(0);
405 }
406 eprintln!("{msg}");
408 }));
409
410 utils::process_guard::spawn_parent_exit_guard();
412
413 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 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 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 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 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 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 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 let merged_context = crate::cli::merge_context_flags(
809 context,
810 context_deprecated,
811 after_context,
812 before_context,
813 );
814
815 commands::find(
817 &inputs,
818 &sources,
819 limit,
820 all,
821 page,
822 false, 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, )
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: _, 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: _, }) => {
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 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 let format = args.format.resolve(quiet);
996
997 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 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 "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 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, 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, limit,
1144 max_depth,
1145 None,
1146 filter.as_deref(),
1147 false,
1148 false, false, false, 1, )
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 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 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 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 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 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 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 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 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 #[test]
1693 fn test_cli_flag_combinations() {
1694 use clap::Parser;
1695
1696 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 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 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 let invalid_combinations = vec![
1832 vec!["blz", "add", "alias"], vec!["blz", "search"], vec!["blz", "lookup"], vec!["blz", "search", "rust", "--limit", "-5"], vec!["blz", "search", "rust", "--page", "-1"], vec!["blz", "list", "--output", "invalid"], ];
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 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 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 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 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 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 let edge_cases = vec![
1951 vec!["blz", "search", "rust", "--limit", "999999"],
1953 vec!["blz", "search", "rust", "--page", "999999"],
1954 vec!["blz", "search", "rust", "--limit", "1"],
1956 vec!["blz", "search", "rust", "--page", "1"],
1957 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 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 let string_cases = vec![
1976 vec!["blz", "search", "normal query"],
1978 vec!["blz", "add", "test-alias", "https://example.com/llms.txt"],
1979 vec!["blz", "lookup", "react"],
1980 vec!["blz", "search", ""], 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"], 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 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 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 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 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 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 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 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 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 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 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 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 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 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 let bool_flag_cases = vec![
2395 (
2397 vec!["blz", "--verbose", "search", "test"],
2398 true,
2399 false,
2400 false,
2401 ),
2402 (vec!["blz", "-v", "search", "test"], true, false, false),
2403 (vec!["blz", "--debug", "search", "test"], false, true, false),
2405 (
2407 vec!["blz", "--profile", "search", "test"],
2408 false,
2409 false,
2410 true,
2411 ),
2412 (
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 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 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 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; } else {
2531 panic!("Expected get command");
2532 }
2533 }
2534
2535 #[test]
2536 fn test_cli_special_argument_parsing() {
2537 use clap::Parser;
2538
2539 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 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 let error_cases = vec![
2576 (vec!["blz", "add"], "missing"),
2578 (vec!["blz", "search"], "required"),
2579 (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 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 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 #[test]
2636 fn test_completion_generation_for_all_shells() {
2637 use clap_complete::Shell;
2638
2639 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 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 let cmd = Cli::command();
2668
2669 let subcommands: Vec<&str> = cmd.get_subcommands().map(clap::Command::get_name).collect();
2670
2671 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 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 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 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 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 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 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 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 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 let shells_to_test = vec![Shell::Bash, Shell::Zsh, Shell::Fish];
2851
2852 for shell in shells_to_test {
2853 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 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 },
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 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"], ];
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 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 let cmd = Cli::command();
2960
2961 assert_eq!(cmd.get_name(), "blz", "Command name should be 'blz'");
2963
2964 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 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 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 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 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 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 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 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()); 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 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 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()); 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 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 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()); assert_eq!(limit, Some(5));
3175 },
3176 _ => panic!("expected search command"),
3177 }
3178 }
3179}