1use std::ffi::OsStr;
2use std::path::PathBuf;
3
4use clap::{ArgAction, Parser, ValueEnum};
5
6fn parse_confidence(s: &str) -> Result<f64, String> {
7 let value: f64 = s.parse().map_err(|e| format!("{e}"))?;
8 if !(0.0..=1.0).contains(&value) {
9 return Err(format!(
10 "confidence must be between 0.0 and 1.0, got {value}"
11 ));
12 }
13 Ok(value)
14}
15
16#[derive(Debug, Clone, Copy, Default, ValueEnum)]
17pub enum OutputFormat {
18 #[default]
19 Json,
20 Toon,
21}
22
23#[derive(Debug, Clone, Copy, Default, ValueEnum)]
24pub enum GraphFormat {
25 #[default]
26 Json,
27 Dot,
28 Mermaid,
29}
30
31#[derive(Debug, Clone, Copy, Default, ValueEnum)]
32pub enum GraphPreset {
33 #[default]
34 Compact,
35 Roomy,
36}
37
38#[derive(Debug, Clone, Copy, Default, ValueEnum)]
39pub enum GraphStyle {
40 #[default]
41 Force,
42 Grid,
43}
44
45#[derive(Debug, Parser)]
46#[command(
47 name = "bvr",
48 about = "Rust port of beads_viewer (bv)",
49 disable_help_subcommand = true,
50 disable_version_flag = true
51)]
52pub struct Cli {
53 #[arg(short = 'V', long = "version", action = ArgAction::SetTrue)]
54 pub version: bool,
55
56 #[arg(long, action = ArgAction::SetTrue)]
58 pub check_update: bool,
59
60 #[arg(long, value_enum, default_value_t = OutputFormat::Json)]
61 pub format: OutputFormat,
62
63 #[arg(long, action = ArgAction::SetTrue)]
64 pub robot_help: bool,
65
66 #[arg(long)]
67 pub robot_docs: Option<String>,
68
69 #[arg(long, action = ArgAction::SetTrue)]
70 pub robot_schema: bool,
71
72 #[arg(long)]
73 pub schema_command: Option<String>,
74
75 #[arg(long, action = ArgAction::SetTrue)]
76 pub stats: bool,
77
78 #[arg(long, action = ArgAction::SetTrue)]
79 pub robot_next: bool,
80
81 #[arg(long, visible_alias = "robot-orient", action = ArgAction::SetTrue)]
82 pub robot_overview: bool,
83
84 #[arg(long, action = ArgAction::SetTrue)]
85 pub robot_triage: bool,
86
87 #[arg(long, action = ArgAction::SetTrue)]
88 pub robot_triage_by_track: bool,
89
90 #[arg(long, action = ArgAction::SetTrue)]
91 pub robot_triage_by_label: bool,
92
93 #[arg(long, action = ArgAction::SetTrue)]
94 pub robot_plan: bool,
95
96 #[arg(long, action = ArgAction::SetTrue)]
97 pub robot_insights: bool,
98
99 #[arg(long, action = ArgAction::SetTrue)]
101 pub robot_full_stats: bool,
102
103 #[arg(long, default_value_t = 20)]
105 pub insight_limit: usize,
106
107 #[arg(long, action = ArgAction::SetTrue)]
108 pub robot_priority: bool,
109
110 #[arg(long, action = ArgAction::SetTrue)]
111 pub robot_alerts: bool,
112
113 #[arg(long, action = ArgAction::SetTrue)]
117 pub robot_economics: bool,
118
119 #[arg(long)]
125 pub economics_overlay: Option<std::path::PathBuf>,
126
127 #[arg(long, action = ArgAction::SetTrue)]
130 pub robot_delivery: bool,
131
132 #[arg(long)]
133 pub severity: Option<String>,
134
135 #[arg(long)]
136 pub alert_type: Option<String>,
137
138 #[arg(long)]
139 pub alert_label: Option<String>,
140
141 #[arg(long, action = ArgAction::SetTrue)]
142 pub robot_suggest: bool,
143
144 #[arg(long)]
145 pub suggest_type: Option<String>,
146
147 #[arg(long, default_value_t = 0.0, value_parser = parse_confidence)]
148 pub suggest_confidence: f64,
149
150 #[arg(long)]
151 pub suggest_bead: Option<String>,
152
153 #[arg(long, action = ArgAction::SetTrue)]
154 pub robot_diff: bool,
155
156 #[arg(long)]
157 pub diff_since: Option<String>,
158
159 #[arg(long, action = ArgAction::SetTrue)]
160 pub robot_history: bool,
161
162 #[arg(long)]
163 pub bead_history: Option<String>,
164
165 #[arg(long, default_value_t = 500)]
166 pub history_limit: usize,
167
168 #[arg(long)]
169 pub history_since: Option<String>,
170
171 #[arg(long = "min-confidence", default_value_t = 0.0, value_parser = parse_confidence)]
172 pub history_min_confidence: f64,
173
174 #[arg(long)]
175 pub robot_burndown: Option<String>,
176
177 #[arg(long)]
178 pub robot_forecast: Option<String>,
179
180 #[arg(long, action = ArgAction::SetTrue)]
181 pub robot_graph: bool,
182
183 #[arg(long, value_enum, default_value_t = GraphFormat::Json)]
184 pub graph_format: GraphFormat,
185
186 #[arg(long)]
187 pub graph_root: Option<String>,
188
189 #[arg(long, default_value_t = 0)]
190 pub graph_depth: usize,
191
192 #[arg(long, value_enum, default_value_t = GraphPreset::Compact)]
193 pub graph_preset: GraphPreset,
194
195 #[arg(long, value_enum, default_value_t = GraphStyle::Force)]
196 pub graph_style: GraphStyle,
197
198 #[arg(long)]
199 pub graph_title: Option<String>,
200
201 #[arg(long)]
202 pub export_graph: Option<PathBuf>,
203
204 #[arg(long)]
205 pub forecast_label: Option<String>,
206
207 #[arg(long)]
208 pub forecast_sprint: Option<String>,
209
210 #[arg(long, default_value_t = 1)]
211 pub forecast_agents: usize,
212
213 #[arg(long, action = ArgAction::SetTrue)]
214 pub robot_capacity: bool,
215
216 #[arg(long = "agents", default_value_t = 1)]
217 pub capacity_agents: usize,
218
219 #[arg(long)]
220 pub capacity_label: Option<String>,
221
222 #[arg(long, default_value_t = 10)]
223 pub robot_max_results: usize,
224
225 #[arg(long, default_value_t = 0.0)]
226 pub robot_min_confidence: f64,
227
228 #[arg(long)]
229 pub robot_by_label: Option<String>,
230
231 #[arg(long)]
232 pub robot_by_assignee: Option<String>,
233
234 #[arg(long)]
235 pub label: Option<String>,
236
237 #[arg(long)]
238 pub workspace: Option<PathBuf>,
239
240 #[arg(short = 'r', long)]
241 pub repo: Option<String>,
242
243 #[arg(long, action = ArgAction::SetTrue)]
244 pub robot_sprint_list: bool,
245
246 #[arg(long)]
247 pub robot_sprint_show: Option<String>,
248
249 #[arg(long, action = ArgAction::SetTrue)]
250 pub robot_metrics: bool,
251
252 #[arg(long, action = ArgAction::SetTrue)]
253 pub robot_label_health: bool,
254
255 #[arg(long, action = ArgAction::SetTrue)]
256 pub robot_label_flow: bool,
257
258 #[arg(long, action = ArgAction::SetTrue)]
259 pub robot_label_attention: bool,
260
261 #[arg(long, default_value_t = 0)]
262 pub attention_limit: usize,
263
264 #[arg(long)]
265 pub robot_explain_correlation: Option<String>,
266
267 #[arg(long)]
268 pub robot_confirm_correlation: Option<String>,
269
270 #[arg(long)]
271 pub robot_reject_correlation: Option<String>,
272
273 #[arg(long)]
274 pub correlation_by: Option<String>,
275
276 #[arg(long)]
277 pub correlation_reason: Option<String>,
278
279 #[arg(long, action = ArgAction::SetTrue)]
280 pub robot_correlation_stats: bool,
281
282 #[arg(long, action = ArgAction::SetTrue)]
283 pub robot_orphans: bool,
284
285 #[arg(long, default_value_t = 30)]
286 pub orphans_min_score: u32,
287
288 #[arg(long)]
289 pub robot_file_beads: Option<String>,
290
291 #[arg(long, default_value_t = 20)]
292 pub file_beads_limit: usize,
293
294 #[arg(long, action = ArgAction::SetTrue)]
295 pub robot_file_hotspots: bool,
296
297 #[arg(long, default_value_t = 10)]
298 pub hotspots_limit: usize,
299
300 #[arg(long)]
301 pub robot_impact: Option<String>,
302
303 #[arg(long)]
304 pub robot_file_relations: Option<String>,
305
306 #[arg(long, default_value_t = 0.5)]
307 pub relations_threshold: f64,
308
309 #[arg(long, default_value_t = 10)]
310 pub relations_limit: usize,
311
312 #[arg(long)]
313 pub robot_related: Option<String>,
314
315 #[arg(long, default_value_t = 20)]
316 pub related_min_relevance: u32,
317
318 #[arg(long, default_value_t = 10)]
319 pub related_max_results: usize,
320
321 #[arg(long)]
322 pub robot_blocker_chain: Option<String>,
323
324 #[arg(long)]
325 pub robot_impact_network: Option<String>,
326
327 #[arg(long, default_value_t = 2)]
328 pub network_depth: usize,
329
330 #[arg(long)]
331 pub robot_causality: Option<String>,
332
333 #[arg(long)]
334 pub save_baseline: Option<String>,
335
336 #[arg(long, action = ArgAction::SetTrue)]
337 pub robot_drift: bool,
338
339 #[arg(long)]
340 pub search: Option<String>,
341
342 #[arg(long, action = ArgAction::SetTrue)]
343 pub robot_search: bool,
344
345 #[arg(long, default_value_t = 10)]
346 pub search_limit: usize,
347
348 #[arg(long)]
349 pub search_mode: Option<String>,
350
351 #[arg(long)]
352 pub search_preset: Option<String>,
353
354 #[arg(long)]
355 pub search_weights: Option<String>,
356
357 #[arg(long, action = ArgAction::SetTrue)]
359 pub robot_recipes: bool,
360
361 #[arg(long)]
363 pub recipe: Option<String>,
364
365 #[arg(long)]
367 pub weight_preset: Option<String>,
368
369 #[arg(long, action = ArgAction::SetTrue)]
371 pub emit_script: bool,
372
373 #[arg(long, default_value_t = 5)]
375 pub script_limit: usize,
376
377 #[arg(long, default_value = "bash")]
379 pub script_format: String,
380
381 #[arg(long)]
383 pub feedback_accept: Option<String>,
384
385 #[arg(long)]
387 pub feedback_ignore: Option<String>,
388
389 #[arg(long, action = ArgAction::SetTrue)]
391 pub feedback_show: bool,
392
393 #[arg(long, action = ArgAction::SetTrue)]
395 pub feedback_reset: bool,
396
397 #[arg(long)]
399 pub priority_brief: Option<PathBuf>,
400
401 #[arg(long)]
403 pub agent_brief: Option<PathBuf>,
404
405 #[arg(long)]
407 pub export_pages: Option<PathBuf>,
408
409 #[arg(long)]
411 pub preview_pages: Option<PathBuf>,
412
413 #[arg(long, action = ArgAction::SetTrue)]
415 pub watch_export: bool,
416
417 #[arg(long, action = ArgAction::SetTrue)]
419 pub pages: bool,
420
421 #[arg(long, action = ArgAction::Set, default_value_t = true)]
423 pub pages_include_closed: bool,
424
425 #[arg(long, action = ArgAction::Set, default_value_t = true)]
427 pub pages_include_history: bool,
428
429 #[arg(long)]
431 pub pages_title: Option<String>,
432
433 #[arg(long)]
435 pub pages_subtitle: Option<String>,
436
437 #[arg(long, action = ArgAction::SetTrue)]
439 pub no_live_reload: bool,
440
441 #[arg(long, action = ArgAction::SetTrue)]
443 pub background_mode: bool,
444
445 #[arg(long, action = ArgAction::SetTrue)]
447 pub no_background_mode: bool,
448
449 #[arg(long)]
450 pub export_md: Option<PathBuf>,
451
452 #[arg(long, action = ArgAction::SetTrue)]
453 pub no_hooks: bool,
454
455 #[arg(long)]
459 pub view: Option<String>,
460
461 #[arg(long)]
464 pub list_filter: Option<String>,
465
466 #[arg(long)]
469 pub debug_render: Option<String>,
470
471 #[arg(long, default_value_t = 180)]
473 pub debug_width: u16,
474
475 #[arg(long, default_value_t = 50)]
477 pub debug_height: u16,
478
479 #[arg(long, action = ArgAction::SetTrue)]
481 pub agents_check: bool,
482
483 #[arg(long, action = ArgAction::SetTrue)]
485 pub agents_add: bool,
486
487 #[arg(long, action = ArgAction::SetTrue)]
489 pub agents_update: bool,
490
491 #[arg(long, action = ArgAction::SetTrue)]
493 pub agents_remove: bool,
494
495 #[arg(long, action = ArgAction::SetTrue)]
497 pub agents_dry_run: bool,
498
499 #[arg(long, action = ArgAction::SetTrue)]
501 pub agents_force: bool,
502
503 #[arg(long)]
504 pub as_of: Option<String>,
505
506 #[arg(long, action = ArgAction::SetTrue)]
507 pub force_full_analysis: bool,
508
509 #[arg(long, action = ArgAction::SetTrue)]
511 pub profile_startup: bool,
512
513 #[arg(long, action = ArgAction::SetTrue)]
515 pub profile_json: bool,
516
517 #[arg(long, action = ArgAction::SetTrue)]
519 pub no_cache: bool,
520
521 #[arg(long)]
523 pub db: Option<PathBuf>,
524
525 #[arg(long, action = ArgAction::SetTrue)]
527 pub baseline_info: bool,
528
529 #[arg(long, action = ArgAction::SetTrue)]
531 pub check_drift: bool,
532
533 #[arg(long, action = ArgAction::SetTrue)]
535 pub related_include_closed: bool,
536
537 #[arg(long, hide = true)]
538 pub beads_file: Option<PathBuf>,
539
540 #[arg(long, hide = true)]
541 pub repo_path: Option<PathBuf>,
542}
543
544impl Cli {
545 pub fn resolve_output_format(&self) -> std::result::Result<OutputFormat, String> {
546 let cli_explicit = format_flag_was_explicit_in_args(std::env::args_os().skip(1));
547 resolve_output_format_choice(
548 self.format,
549 cli_explicit,
550 std::env::var("BV_OUTPUT_FORMAT").ok().as_deref(),
551 std::env::var("TOON_DEFAULT_FORMAT").ok().as_deref(),
552 )
553 }
554
555 #[must_use]
556 pub fn resolve_stats_flag(&self) -> bool {
557 self.stats || std::env::var("TOON_STATS").is_ok_and(|value| value.trim() == "1")
558 }
559
560 #[must_use]
561 pub fn resolve_search_preset(&self) -> Option<String> {
562 resolve_optional_string_choice(
563 self.search_preset.as_deref(),
564 std::env::var("BV_SEARCH_PRESET").ok().as_deref(),
565 )
566 }
567
568 #[must_use]
569 pub fn is_operational_command(&self) -> bool {
570 self.check_update
571 }
572
573 #[must_use]
574 pub fn is_robot_command(&self) -> bool {
575 self.robot_help
576 || self.robot_next
577 || self.robot_overview
578 || self.robot_triage
579 || self.robot_triage_by_track
580 || self.robot_triage_by_label
581 || self.robot_plan
582 || self.robot_insights
583 || self.robot_priority
584 || self.robot_alerts
585 || self.robot_economics
586 || self.robot_delivery
587 || self.robot_suggest
588 || self.robot_diff
589 || self.robot_history
590 || self.robot_burndown.is_some()
591 || self.robot_graph
592 || self.robot_forecast.is_some()
593 || self.robot_capacity
594 || self.bead_history.is_some()
595 || self.robot_docs.is_some()
596 || self.robot_schema
597 || self.robot_sprint_list
598 || self.robot_sprint_show.is_some()
599 || self.robot_metrics
600 || self.robot_label_health
601 || self.robot_label_flow
602 || self.robot_label_attention
603 || self.robot_explain_correlation.is_some()
604 || self.robot_confirm_correlation.is_some()
605 || self.robot_reject_correlation.is_some()
606 || self.robot_correlation_stats
607 || self.robot_orphans
608 || self.robot_file_beads.is_some()
609 || self.robot_file_hotspots
610 || self.robot_impact.is_some()
611 || self.robot_file_relations.is_some()
612 || self.robot_related.is_some()
613 || self.robot_blocker_chain.is_some()
614 || self.robot_impact_network.is_some()
615 || self.robot_causality.is_some()
616 || self.save_baseline.is_some()
617 || self.robot_drift
618 || self.check_drift
619 || self.robot_search
620 || self.robot_recipes
621 || self.emit_script
622 || self.feedback_show
623 || self.feedback_accept.is_some()
624 || self.feedback_ignore.is_some()
625 || self.feedback_reset
626 || self.priority_brief.is_some()
627 || self.agent_brief.is_some()
628 || self.profile_startup
629 }
630
631 #[must_use]
632 pub fn is_agents_command(&self) -> bool {
633 self.agents_check
634 || self.agents_add
635 || self.agents_update
636 || self.agents_remove
637 || self.agents_dry_run
638 || self.agents_force
639 }
640}
641
642fn resolve_output_format_choice(
643 cli_format: OutputFormat,
644 cli_explicit: bool,
645 bv_output_format: Option<&str>,
646 toon_default_format: Option<&str>,
647) -> std::result::Result<OutputFormat, String> {
648 if cli_explicit {
649 return Ok(cli_format);
650 }
651
652 for (source, raw) in [
653 ("BV_OUTPUT_FORMAT", bv_output_format),
654 ("TOON_DEFAULT_FORMAT", toon_default_format),
655 ] {
656 let Some(raw) = raw.map(str::trim).filter(|value| !value.is_empty()) else {
657 continue;
658 };
659
660 return OutputFormat::from_str(raw, true)
661 .map_err(|_| format!("invalid {source} value {raw:?} (expected json|toon)"));
662 }
663
664 Ok(cli_format)
665}
666
667fn resolve_optional_string_choice(
668 cli_value: Option<&str>,
669 env_value: Option<&str>,
670) -> Option<String> {
671 cli_value
672 .map(str::trim)
673 .filter(|value| !value.is_empty())
674 .map(std::string::ToString::to_string)
675 .or_else(|| {
676 env_value
677 .map(str::trim)
678 .filter(|value| !value.is_empty())
679 .map(std::string::ToString::to_string)
680 })
681}
682
683fn format_flag_was_explicit_in_args<I, S>(args: I) -> bool
684where
685 I: IntoIterator<Item = S>,
686 S: AsRef<OsStr>,
687{
688 args.into_iter().any(|arg| {
689 let text = arg.as_ref().to_string_lossy();
690 text == "--format" || text.starts_with("--format=")
691 })
692}
693
694#[cfg(test)]
695mod tests {
696 use clap::Parser;
697
698 use super::{
699 Cli, OutputFormat, format_flag_was_explicit_in_args, resolve_optional_string_choice,
700 resolve_output_format_choice,
701 };
702
703 #[test]
704 fn parse_operational_flags() {
705 let cli = Cli::parse_from(["bvr", "--check-update"]);
706 assert!(cli.check_update);
707 assert!(cli.is_operational_command());
708 }
709
710 #[test]
711 fn parse_agents_force_as_agents_command() {
712 let cli = Cli::parse_from(["bvr", "--agents-force"]);
713 assert!(cli.agents_force);
714 assert!(cli.is_agents_command());
715 }
716
717 #[test]
718 fn parse_pages_flags() {
719 let cli = Cli::parse_from([
720 "bvr",
721 "--export-pages",
722 "bundle",
723 "--watch-export",
724 "--pages-title",
725 "Dashboard",
726 "--pages-subtitle",
727 "Triage View",
728 "--pages-include-closed=false",
729 "--pages-include-history=false",
730 ]);
731
732 assert_eq!(
733 cli.export_pages
734 .as_deref()
735 .and_then(std::path::Path::to_str),
736 Some("bundle")
737 );
738 assert!(cli.watch_export);
739 assert_eq!(cli.pages_title.as_deref(), Some("Dashboard"));
740 assert_eq!(cli.pages_subtitle.as_deref(), Some("Triage View"));
741 assert!(!cli.pages_include_closed);
742 assert!(!cli.pages_include_history);
743 }
744
745 #[test]
746 fn parse_background_mode_flags() {
747 let cli = Cli::parse_from(["bvr", "--background-mode", "--no-background-mode"]);
748 assert!(cli.background_mode);
749 assert!(cli.no_background_mode);
750 }
751
752 #[test]
753 fn explicit_format_flag_detected_with_split_syntax() {
754 assert!(format_flag_was_explicit_in_args([
755 "--robot-next",
756 "--format",
757 "toon"
758 ]));
759 }
760
761 #[test]
762 fn explicit_format_flag_detected_with_equals_syntax() {
763 assert!(format_flag_was_explicit_in_args([
764 "--robot-next",
765 "--format=toon"
766 ]));
767 }
768
769 #[test]
770 fn resolve_output_format_uses_env_when_cli_flag_absent() {
771 let resolved = resolve_output_format_choice(OutputFormat::Json, false, Some("toon"), None)
772 .expect("format");
773 assert!(matches!(resolved, OutputFormat::Toon));
774 }
775
776 #[test]
777 fn resolve_output_format_prefers_cli_when_flag_explicit() {
778 let resolved = resolve_output_format_choice(OutputFormat::Json, true, Some("toon"), None)
779 .expect("format");
780 assert!(matches!(resolved, OutputFormat::Json));
781 }
782
783 #[test]
784 fn resolve_output_format_falls_back_to_secondary_env() {
785 let resolved = resolve_output_format_choice(OutputFormat::Json, false, None, Some("toon"))
786 .expect("format");
787 assert!(matches!(resolved, OutputFormat::Toon));
788 }
789
790 #[test]
791 fn resolve_output_format_rejects_invalid_env_values() {
792 let error = resolve_output_format_choice(OutputFormat::Json, false, Some("yaml"), None)
793 .expect_err("invalid env should fail");
794 assert!(error.contains("BV_OUTPUT_FORMAT"));
795 assert!(error.contains("json|toon"));
796 }
797
798 #[test]
799 fn resolve_search_preset_uses_env_when_cli_flag_absent() {
800 let resolved = resolve_optional_string_choice(None, Some("impact-first"));
801 assert_eq!(resolved.as_deref(), Some("impact-first"));
802 }
803
804 #[test]
805 fn resolve_search_preset_prefers_cli_over_env() {
806 let resolved = resolve_optional_string_choice(Some("text-only"), Some("impact-first"));
807 assert_eq!(resolved.as_deref(), Some("text-only"));
808 }
809
810 #[test]
811 fn resolve_search_preset_ignores_blank_values() {
812 let resolved = resolve_optional_string_choice(Some(" "), Some(" "));
813 assert_eq!(resolved, None);
814 }
815
816 #[test]
817 fn parse_no_cache_flag() {
818 let cli = Cli::parse_from(["bvr", "--no-cache", "--robot-triage"]);
819 assert!(cli.no_cache);
820 }
821
822 #[test]
823 fn parse_db_flag() {
824 let cli = Cli::parse_from(["bvr", "--db", "/tmp/test.jsonl", "--robot-triage"]);
825 assert_eq!(
826 cli.db.as_deref().and_then(std::path::Path::to_str),
827 Some("/tmp/test.jsonl")
828 );
829 }
830
831 #[test]
832 fn parse_baseline_info_flag() {
833 let cli = Cli::parse_from(["bvr", "--baseline-info"]);
834 assert!(cli.baseline_info);
835 assert!(!cli.is_robot_command());
837 }
838
839 #[test]
840 fn parse_check_drift_flag() {
841 let cli = Cli::parse_from(["bvr", "--check-drift"]);
842 assert!(cli.check_drift);
843 assert!(cli.is_robot_command());
844 }
845
846 #[test]
847 fn parse_related_include_closed_flag() {
848 let cli = Cli::parse_from(["bvr", "--robot-related", "bd-1", "--related-include-closed"]);
849 assert!(cli.related_include_closed);
850 }
851
852 #[test]
853 fn robot_orient_is_alias_for_robot_overview() {
854 let overview = Cli::parse_from(["bvr", "--robot-overview"]);
855 let orient = Cli::parse_from(["bvr", "--robot-orient"]);
856 assert!(overview.robot_overview);
857 assert!(orient.robot_overview);
858 }
859}