Skip to main content

bvr/
cli.rs

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    /// Check whether a newer bvr version is available.
57    #[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    /// Include full per-node metric maps in robot-insights output.
100    #[arg(long, action = ArgAction::SetTrue)]
101    pub robot_full_stats: bool,
102
103    /// Maximum items per insight category (bottlenecks, influencers, etc.).
104    #[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    /// Emit economics projections (burn rate, cost-to-complete, cost-of-delay).
114    /// Requires `--economics-overlay <path>` or `BVR_ECONOMICS_OVERLAY` env var
115    /// pointing at a JSON file with `hourly_rate` and `hours_per_day`.
116    #[arg(long, action = ArgAction::SetTrue)]
117    pub robot_economics: bool,
118
119    /// Path to a JSON economics overlay file. Overrides `BVR_ECONOMICS_OVERLAY`
120    /// when both are set. Required for `--robot-economics` unless the env var
121    /// is set. Schema: `{"hourly_rate": f64, "hours_per_day": f64,
122    /// "budget_envelope": f64?, "throughput_window_days": u32?,
123    /// "currency": String?}`.
124    #[arg(long)]
125    pub economics_overlay: Option<std::path::PathBuf>,
126
127    /// Emit delivery posture classification (flow mix, urgency profile,
128    /// milestone pressure). No overlay required.
129    #[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    /// List available triage recipes.
358    #[arg(long, action = ArgAction::SetTrue)]
359    pub robot_recipes: bool,
360
361    /// Apply a named recipe to filter/sort recommendations.
362    #[arg(long)]
363    pub recipe: Option<String>,
364
365    /// Scoring weight preset (default, graph-heavy, priority-first, quick-wins, risk-averse).
366    #[arg(long)]
367    pub weight_preset: Option<String>,
368
369    /// Emit a shell script for the top recommendations.
370    #[arg(long, action = ArgAction::SetTrue)]
371    pub emit_script: bool,
372
373    /// Number of recommendations to include in emitted script (default 5).
374    #[arg(long, default_value_t = 5)]
375    pub script_limit: usize,
376
377    /// Shell format for emitted script: bash (default), fish, zsh.
378    #[arg(long, default_value = "bash")]
379    pub script_format: String,
380
381    /// Record positive feedback for a recommendation.
382    #[arg(long)]
383    pub feedback_accept: Option<String>,
384
385    /// Record negative feedback (ignore) for a recommendation.
386    #[arg(long)]
387    pub feedback_ignore: Option<String>,
388
389    /// Show feedback statistics.
390    #[arg(long, action = ArgAction::SetTrue)]
391    pub feedback_show: bool,
392
393    /// Reset all recorded feedback.
394    #[arg(long, action = ArgAction::SetTrue)]
395    pub feedback_reset: bool,
396
397    /// Generate a priority brief as markdown and write to the given path.
398    #[arg(long)]
399    pub priority_brief: Option<PathBuf>,
400
401    /// Generate an agent brief bundle in the given directory.
402    #[arg(long)]
403    pub agent_brief: Option<PathBuf>,
404
405    /// Export static pages bundle to directory.
406    #[arg(long)]
407    pub export_pages: Option<PathBuf>,
408
409    /// Preview an existing static pages bundle from directory.
410    #[arg(long)]
411    pub preview_pages: Option<PathBuf>,
412
413    /// Watch beads file changes and auto-regenerate pages export.
414    #[arg(long, action = ArgAction::SetTrue)]
415    pub watch_export: bool,
416
417    /// Launch pages deployment wizard.
418    #[arg(long, action = ArgAction::SetTrue)]
419    pub pages: bool,
420
421    /// Include closed issues in exported pages bundle (default: true).
422    #[arg(long, action = ArgAction::Set, default_value_t = true)]
423    pub pages_include_closed: bool,
424
425    /// Include history payload in exported pages bundle (default: true).
426    #[arg(long, action = ArgAction::Set, default_value_t = true)]
427    pub pages_include_history: bool,
428
429    /// Custom title for exported pages bundle.
430    #[arg(long)]
431    pub pages_title: Option<String>,
432
433    /// Custom subtitle for exported pages bundle.
434    #[arg(long)]
435    pub pages_subtitle: Option<String>,
436
437    /// Disable live reload when previewing pages.
438    #[arg(long, action = ArgAction::SetTrue)]
439    pub no_live_reload: bool,
440
441    /// Enable experimental background snapshot loading (TUI only).
442    #[arg(long, action = ArgAction::SetTrue)]
443    pub background_mode: bool,
444
445    /// Disable experimental background snapshot loading (TUI only).
446    #[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    /// Start the TUI in the given view instead of Main.
456    /// Supported: main, board, insights, graph, history, actionable,
457    /// attention, tree, labels, flow, timediff, sprint.
458    #[arg(long)]
459    pub view: Option<String>,
460
461    /// Start the TUI with a list-status filter applied.
462    /// Supported: all, open, in-progress, blocked, closed, ready.
463    #[arg(long)]
464    pub list_filter: Option<String>,
465
466    /// Render a named TUI view non-interactively and output to stdout.
467    /// Supported views: insights, board, history, main, graph.
468    #[arg(long)]
469    pub debug_render: Option<String>,
470
471    /// Width in columns for debug render (default 180).
472    #[arg(long, default_value_t = 180)]
473    pub debug_width: u16,
474
475    /// Height in rows for debug render (default 50).
476    #[arg(long, default_value_t = 50)]
477    pub debug_height: u16,
478
479    /// Check agent file blurb status.
480    #[arg(long, action = ArgAction::SetTrue)]
481    pub agents_check: bool,
482
483    /// Add beads workflow blurb to agent file (creates AGENTS.md if needed).
484    #[arg(long, action = ArgAction::SetTrue)]
485    pub agents_add: bool,
486
487    /// Update blurb to current version in agent file.
488    #[arg(long, action = ArgAction::SetTrue)]
489    pub agents_update: bool,
490
491    /// Remove blurb from agent file.
492    #[arg(long, action = ArgAction::SetTrue)]
493    pub agents_remove: bool,
494
495    /// Dry-run mode for agents commands (show what would change without writing).
496    #[arg(long, action = ArgAction::SetTrue)]
497    pub agents_dry_run: bool,
498
499    /// Skip confirmation prompts for agents commands (legacy compatibility flag).
500    #[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    /// Output detailed startup timing profile for diagnostics.
510    #[arg(long, action = ArgAction::SetTrue)]
511    pub profile_startup: bool,
512
513    /// Output profile in JSON format (use with --profile-startup).
514    #[arg(long, action = ArgAction::SetTrue)]
515    pub profile_json: bool,
516
517    /// Bypass disk cache for this invocation.
518    #[arg(long, action = ArgAction::SetTrue)]
519    pub no_cache: bool,
520
521    /// Legacy compatibility alias for `--beads-file`.
522    #[arg(long)]
523    pub db: Option<PathBuf>,
524
525    /// Show baseline metadata (when it was saved, description, stats).
526    #[arg(long, action = ArgAction::SetTrue)]
527    pub baseline_info: bool,
528
529    /// Compare current state against saved baseline with human-readable output.
530    #[arg(long, action = ArgAction::SetTrue)]
531    pub check_drift: bool,
532
533    /// Include closed issues in related work discovery.
534    #[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        // baseline_info doesn't need issues loaded, so it's not a robot command
836        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}