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