1use clap::{Parser, ValueEnum};
9
10#[derive(Parser, Debug, Clone)]
11#[command(
12 name = "ai-usagebar",
13 version,
14 args_conflicts_with_subcommands = true,
15 about = "Waybar widget and terminal dashboard for multi-provider AI plan usage",
16 long_about = "\
17Drop-in replacement for `claudebar` with multi-vendor support.
18
19Output modes:
20 - Default: Waybar JSON ({text, tooltip, class}). Used when stdout is piped.
21 - --pretty: human-readable terminal output for local testing. Auto-enabled
22 when stdout is a TTY, so just running `ai-usagebar --vendor anthropic`
23 in a terminal Does The Right Thing.
24 - --watch N: like --pretty but refreshes every N seconds, clearing the screen
25 between ticks. Useful while iterating on `--format` or `--tooltip-format`.
26 - --json: force JSON output even when stdout is a TTY (for scripting).
27 - --config PATH: read and write an alternate config file instead of the
28 default `%APPDATA%/ai-usagebar/config.toml` (Windows) or
29 `~/.config/ai-usagebar/config.toml`. Accepted in any position, before or
30 after the subcommand; the file must already exist, and Settings saves
31 write back to it."
32)]
33pub struct Cli {
34 #[arg(long, value_enum)]
38 pub vendor: Option<Vendor>,
39
40 #[arg(long)]
43 pub icon: Option<String>,
44
45 #[arg(long)]
49 pub format: Option<String>,
50
51 #[arg(long)]
54 pub tooltip_format: Option<String>,
55
56 #[arg(long, default_value_t = 5)]
58 pub pace_tolerance: u32,
59
60 #[arg(long)]
63 pub format_pace_color: bool,
64
65 #[arg(long)]
69 pub tooltip_pace_pts: bool,
70
71 #[arg(long)]
73 pub color_low: Option<String>,
74 #[arg(long)]
76 pub color_mid: Option<String>,
77 #[arg(long)]
79 pub color_high: Option<String>,
80 #[arg(long)]
82 pub color_critical: Option<String>,
83
84 #[arg(long)]
87 pub pretty: bool,
88
89 #[arg(long, conflicts_with = "pretty")]
92 pub json: bool,
93
94 #[arg(long, value_name = "SECS")]
97 pub watch: Option<u64>,
98
99 #[arg(long, conflicts_with_all = ["cycle_prev", "watch", "pretty", "json"])]
104 pub cycle_next: bool,
105
106 #[arg(long, conflicts_with_all = ["cycle_next", "watch", "pretty", "json"])]
108 pub cycle_prev: bool,
109
110 #[arg(long, value_name = "DIR")]
114 pub cache_dir: Option<std::path::PathBuf>,
115
116 #[arg(long, value_name = "FILE")]
122 pub creds_path: Option<std::path::PathBuf>,
123
124 #[arg(long, value_name = "LABEL", conflicts_with = "creds_path")]
129 pub account: Option<String>,
130
131 #[arg(long, requires = "account")]
136 pub desktop: bool,
137
138 #[command(subcommand)]
140 pub command: Option<Command>,
141}
142
143#[derive(clap::Subcommand, Debug, Clone)]
144pub enum Command {
145 Account {
147 #[command(subcommand)]
148 action: AccountAction,
149 },
150
151 Usage {
158 #[arg(long)]
160 json: bool,
161 },
162
163 Detect {
166 #[arg(long)]
168 all: bool,
169 #[arg(long)]
171 json: bool,
172 },
173
174 Vendors {
179 #[arg(long)]
181 json: bool,
182 },
183
184 Settings {
186 #[command(subcommand)]
187 action: SettingsAction,
188 },
189
190 Auth {
192 #[command(subcommand)]
193 provider: AuthProvider,
194 },
195}
196
197#[derive(clap::Subcommand, Debug, Clone)]
198pub enum AuthProvider {
199 Nous {
200 #[command(subcommand)]
201 action: NousAuthAction,
202 },
203}
204
205#[derive(clap::Subcommand, Debug, Clone)]
206pub enum NousAuthAction {
207 Login,
209 Logout,
211}
212
213#[derive(clap::Subcommand, Debug, Clone)]
214pub enum SettingsAction {
215 Enable {
217 #[arg(value_enum)]
218 vendor: Vendor,
219 },
220
221 Show,
223
224 Apply,
226}
227
228#[derive(clap::Subcommand, Debug, Clone)]
229pub enum AccountAction {
230 Add {
232 label: String,
234
235 #[arg(long, conflicts_with = "desktop")]
237 no_login: bool,
238
239 #[arg(long)]
244 desktop: bool,
245
246 #[arg(long, requires = "desktop")]
249 email: Option<String>,
250
251 #[arg(short = 'y', long, requires = "desktop")]
253 yes: bool,
254
255 #[arg(long, conflicts_with = "desktop")]
259 codex: bool,
260
261 #[arg(long, conflicts_with_all = ["desktop", "no_login"])]
265 adopt_current: bool,
266 },
267
268 Status {
271 #[arg(long)]
273 json: bool,
274 },
275
276 Switch {
279 label: String,
283
284 #[arg(
287 long,
288 conflicts_with_all = ["desktop", "cli", "keep_bridge", "backup_sessions", "delete_conflict"]
289 )]
290 codex: bool,
291
292 #[arg(long)]
294 desktop: bool,
295
296 #[arg(long)]
298 cli: bool,
299
300 #[arg(long)]
302 dry_run: bool,
303
304 #[arg(short = 'y', long)]
306 yes: bool,
307
308 #[arg(long)]
312 force: bool,
313
314 #[arg(long)]
317 keep_bridge: bool,
318
319 #[arg(long)]
322 backup_sessions: bool,
323
324 #[arg(long, default_value_t = 10)]
326 keep_backups: usize,
327
328 #[arg(long, value_name = "KEY")]
335 delete_conflict: Vec<String>,
336 },
337}
338
339#[derive(Debug, Clone, Copy, ValueEnum, PartialEq, Eq)]
340pub enum Vendor {
341 Anthropic,
342 #[value(name = "anthropic_api")]
343 AnthropicApi,
344 Openai,
345 Copilot,
346 Zai,
347 Openrouter,
348 Deepseek,
349 Kimi,
350 Kilo,
351 Novita,
352 Moonshot,
353 Grok,
354 Supergrok,
355 Grokbot,
356 Antigravity,
357 Cursor,
358 Minimax,
359 Kiro,
360 #[value(name = "nous")]
361 NousResearch,
362 #[value(name = "opencode-go")]
363 OpenCodeGo,
364 #[value(name = "commandcode")]
365 CommandCode,
366 Ollama,
367 OrcaRouter,
368 #[value(name = "modelstudio")]
369 ModelStudio,
370}
371
372impl Vendor {
373 pub fn to_id(self) -> crate::vendor::VendorId {
374 match self {
375 Vendor::Anthropic => crate::vendor::VendorId::Anthropic,
376 Vendor::AnthropicApi => crate::vendor::VendorId::AnthropicApi,
377 Vendor::Openai => crate::vendor::VendorId::Openai,
378 Vendor::Copilot => crate::vendor::VendorId::Copilot,
379 Vendor::Zai => crate::vendor::VendorId::Zai,
380 Vendor::Openrouter => crate::vendor::VendorId::Openrouter,
381 Vendor::Deepseek => crate::vendor::VendorId::Deepseek,
382 Vendor::Kimi => crate::vendor::VendorId::Kimi,
383 Vendor::Kilo => crate::vendor::VendorId::Kilo,
384 Vendor::Novita => crate::vendor::VendorId::Novita,
385 Vendor::Moonshot => crate::vendor::VendorId::Moonshot,
386 Vendor::Grok => crate::vendor::VendorId::Grok,
387 Vendor::Supergrok => crate::vendor::VendorId::Supergrok,
388 Vendor::Grokbot => crate::vendor::VendorId::Grokbot,
389 Vendor::Antigravity => crate::vendor::VendorId::Antigravity,
390 Vendor::Cursor => crate::vendor::VendorId::Cursor,
391 Vendor::Minimax => crate::vendor::VendorId::Minimax,
392 Vendor::Kiro => crate::vendor::VendorId::Kiro,
393 Vendor::NousResearch => crate::vendor::VendorId::NousResearch,
394 Vendor::OpenCodeGo => crate::vendor::VendorId::OpenCodeGo,
395 Vendor::CommandCode => crate::vendor::VendorId::CommandCode,
396 Vendor::Ollama => crate::vendor::VendorId::Ollama,
397 Vendor::OrcaRouter => crate::vendor::VendorId::OrcaRouter,
398 Vendor::ModelStudio => crate::vendor::VendorId::ModelStudio,
399 }
400 }
401}
402
403impl Cli {
404 pub fn has_explicit_vendor(&self) -> bool {
406 self.vendor.is_some()
407 }
408
409 pub fn resolved_vendor(&self, config: &crate::config::Config) -> Vendor {
420 let active = if self.has_explicit_vendor() {
426 None
427 } else {
428 crate::active::read()
429 };
430 self.resolve_vendor_with(config, active)
431 }
432
433 pub fn resolve_vendor_with(
438 &self,
439 config: &crate::config::Config,
440 active: Option<crate::vendor::VendorId>,
441 ) -> Vendor {
442 if let Some(v) = self.vendor {
443 return v;
444 }
445 if let Some(id) = active
446 && config.is_enabled(id)
447 {
448 return id_to_vendor(id);
449 }
450 if let Some(id) = config.ui.primary
451 && config.is_enabled(id)
452 {
453 return id_to_vendor(id);
454 }
455 if config.is_enabled(crate::vendor::VendorId::Anthropic) {
456 return Vendor::Anthropic;
457 }
458 config
459 .enabled_vendors()
460 .into_iter()
461 .next()
462 .map(id_to_vendor)
463 .unwrap_or(Vendor::Anthropic)
466 }
467}
468
469fn id_to_vendor(id: crate::vendor::VendorId) -> Vendor {
470 match id {
471 crate::vendor::VendorId::Anthropic => Vendor::Anthropic,
472 crate::vendor::VendorId::AnthropicApi => Vendor::AnthropicApi,
473 crate::vendor::VendorId::Openai => Vendor::Openai,
474 crate::vendor::VendorId::Copilot => Vendor::Copilot,
475 crate::vendor::VendorId::Zai => Vendor::Zai,
476 crate::vendor::VendorId::Openrouter => Vendor::Openrouter,
477 crate::vendor::VendorId::Deepseek => Vendor::Deepseek,
478 crate::vendor::VendorId::Kimi => Vendor::Kimi,
479 crate::vendor::VendorId::Kilo => Vendor::Kilo,
480 crate::vendor::VendorId::Novita => Vendor::Novita,
481 crate::vendor::VendorId::Moonshot => Vendor::Moonshot,
482 crate::vendor::VendorId::Grok => Vendor::Grok,
483 crate::vendor::VendorId::Supergrok => Vendor::Supergrok,
484 crate::vendor::VendorId::Grokbot => Vendor::Grokbot,
485 crate::vendor::VendorId::Antigravity => Vendor::Antigravity,
486 crate::vendor::VendorId::Cursor => Vendor::Cursor,
487 crate::vendor::VendorId::Minimax => Vendor::Minimax,
488 crate::vendor::VendorId::Kiro => Vendor::Kiro,
489 crate::vendor::VendorId::NousResearch => Vendor::NousResearch,
490 crate::vendor::VendorId::OpenCodeGo => Vendor::OpenCodeGo,
491 crate::vendor::VendorId::CommandCode => Vendor::CommandCode,
492 crate::vendor::VendorId::Ollama => Vendor::Ollama,
493 crate::vendor::VendorId::OrcaRouter => Vendor::OrcaRouter,
494 crate::vendor::VendorId::ModelStudio => Vendor::ModelStudio,
495 }
496}
497
498impl Cli {
499 pub fn output_json(&self) -> bool {
502 if self.json {
503 return true;
504 }
505 if self.pretty || self.watch.is_some() {
506 return false;
507 }
508 !is_stdout_tty()
510 }
511}
512
513fn is_stdout_tty() -> bool {
514 use std::io::IsTerminal;
515 std::io::stdout().is_terminal()
516}
517
518#[cfg(test)]
519mod tests {
520 use super::*;
521 use clap::{Parser, error::ErrorKind};
522
523 #[test]
524 fn settings_enable_requires_a_known_vendor() {
525 assert!(matches!(
526 Cli::try_parse_from(["ai-usagebar", "settings", "enable", "anthropic"])
527 .unwrap()
528 .command,
529 Some(Command::Settings {
530 action: SettingsAction::Enable {
531 vendor: Vendor::Anthropic
532 }
533 })
534 ));
535 assert!(Cli::try_parse_from(["ai-usagebar", "settings", "enable", "unknown"]).is_err());
536 assert!(Cli::try_parse_from(["ai-usagebar", "settings", "enable"]).is_err());
537 }
538
539 #[test]
540 fn version_flags_report_the_crate_version() {
541 let expected = format!("ai-usagebar {}\n", env!("CARGO_PKG_VERSION"));
542
543 for flag in ["--version", "-V"] {
544 let err = Cli::try_parse_from(["ai-usagebar", flag])
545 .expect_err("a version flag exits through clap's display path");
546 assert_eq!(err.kind(), ErrorKind::DisplayVersion, "flag: {flag}");
547 assert_eq!(err.to_string(), expected, "flag: {flag}");
548 }
549 }
550
551 #[test]
552 fn usage_subcommand_parses_machine_readable_mode() {
553 let cli = Cli::parse_from(["ai-usagebar", "usage", "--json"]);
554 assert!(matches!(cli.command, Some(Command::Usage { json: true })));
555 }
556
557 #[test]
558 fn usage_help_states_complete_document_exits_zero() {
559 let err = Cli::try_parse_from(["ai-usagebar", "usage", "--help"])
560 .expect_err("help exits through clap's display path");
561 assert_eq!(err.kind(), ErrorKind::DisplayHelp);
562 let help = err.to_string();
563 assert!(
564 help.contains("Exits 0 after printing a complete document"),
565 "{help}"
566 );
567 assert!(
568 help.contains("even when every entry") && help.contains("error"),
569 "{help}"
570 );
571 assert!(
572 help.contains("Non-zero only when the document cannot be produced"),
573 "{help}"
574 );
575 }
576
577 #[test]
578 fn detect_subcommand_parses_its_flags_and_takes_no_widget_flags() {
579 let bare = Cli::parse_from(["ai-usagebar", "detect"]);
580 assert!(matches!(
581 bare.command,
582 Some(Command::Detect {
583 all: false,
584 json: false
585 })
586 ));
587
588 let full = Cli::parse_from(["ai-usagebar", "detect", "--all", "--json"]);
589 assert!(matches!(
590 full.command,
591 Some(Command::Detect {
592 all: true,
593 json: true
594 })
595 ));
596
597 assert!(Cli::try_parse_from(["ai-usagebar", "--vendor", "kimi", "detect"]).is_err());
598 }
599
600 #[test]
601 fn new_vendor_values_and_auth_commands_parse_exactly() {
602 let nous = Cli::parse_from(["ai-usagebar", "--vendor", "nous"]);
603 assert_eq!(nous.vendor, Some(Vendor::NousResearch));
604 let opencode = Cli::parse_from(["ai-usagebar", "--vendor", "opencode-go"]);
605 assert_eq!(opencode.vendor, Some(Vendor::OpenCodeGo));
606 let copilot = Cli::parse_from(["ai-usagebar", "--vendor", "copilot"]);
607 assert_eq!(copilot.vendor, Some(Vendor::Copilot));
608 let login = Cli::parse_from(["ai-usagebar", "auth", "nous", "login"]);
609 assert!(matches!(login.command, Some(Command::Auth { .. })));
610 }
611
612 #[test]
613 fn settings_subcommands_are_additive_and_take_no_widget_flags() {
614 let show = Cli::parse_from(["ai-usagebar", "settings", "show"]);
615 assert!(matches!(
616 show.command,
617 Some(Command::Settings {
618 action: SettingsAction::Show
619 })
620 ));
621
622 let apply = Cli::parse_from(["ai-usagebar", "settings", "apply"]);
623 assert!(matches!(
624 apply.command,
625 Some(Command::Settings {
626 action: SettingsAction::Apply
627 })
628 ));
629
630 assert!(
631 Cli::try_parse_from(["ai-usagebar", "--vendor", "kimi", "settings", "show",]).is_err()
632 );
633 }
634
635 #[test]
636 fn defaults_match_claudebar() {
637 let cli = Cli::parse_from(["ai-usagebar"]);
638 assert_eq!(cli.vendor, None);
639 let cfg = crate::config::Config::default();
644 assert_eq!(cli.resolve_vendor_with(&cfg, None), Vendor::Anthropic);
645 assert_eq!(cli.pace_tolerance, 5);
646 assert!(cli.format.is_none());
647 assert!(cli.tooltip_format.is_none());
648 assert!(cli.icon.is_none());
649 assert!(!cli.format_pace_color);
650 assert!(!cli.tooltip_pace_pts);
651 assert!(!cli.pretty);
652 assert!(!cli.json);
653 assert!(cli.watch.is_none());
654 assert!(cli.command.is_none());
655 }
656
657 #[test]
658 fn account_add_subcommand_parses_without_widget_flags() {
659 let cli = Cli::parse_from(["ai-usagebar", "account", "add", "work", "--no-login"]);
660 assert!(matches!(
661 cli.command,
662 Some(Command::Account {
663 action: AccountAction::Add {
664 ref label,
665 no_login: true,
666 desktop: false,
667 ..
668 }
669 }) if label == "work"
670 ));
671 }
672
673 #[test]
676 fn account_add_desktop_takes_an_email_and_rejects_no_login() {
677 let cli = Cli::parse_from([
678 "ai-usagebar",
679 "account",
680 "add",
681 "work",
682 "--desktop",
683 "--email",
684 "a@b.test",
685 "-y",
686 ]);
687 assert!(matches!(
688 cli.command,
689 Some(Command::Account {
690 action: AccountAction::Add {
691 desktop: true,
692 yes: true,
693 email: Some(ref email),
694 ..
695 }
696 }) if email == "a@b.test"
697 ));
698 assert!(
699 Cli::try_parse_from([
700 "ai-usagebar",
701 "account",
702 "add",
703 "w",
704 "--desktop",
705 "--no-login"
706 ])
707 .is_err()
708 );
709 assert!(
711 Cli::try_parse_from(["ai-usagebar", "account", "add", "w", "--email", "a@b.test"])
712 .is_err()
713 );
714 }
715
716 #[test]
717 fn account_switch_defaults_to_both_surfaces() {
718 let cli = Cli::parse_from(["ai-usagebar", "account", "switch", "work", "--dry-run"]);
719 assert!(matches!(
720 cli.command,
721 Some(Command::Account {
722 action: AccountAction::Switch {
723 ref label,
724 desktop: false,
725 cli: false,
726 dry_run: true,
727 keep_backups: 10,
728 ..
729 }
730 }) if label == "work"
731 ));
732 }
733
734 #[test]
735 fn account_subcommand_rejects_ignored_widget_flags() {
736 assert!(
737 Cli::try_parse_from([
738 "ai-usagebar",
739 "--vendor",
740 "anthropic",
741 "account",
742 "add",
743 "work",
744 ])
745 .is_err()
746 );
747 }
748
749 #[test]
750 fn multi_account_flags_are_stable_api() {
751 let cli = Cli::parse_from([
755 "ai-usagebar",
756 "--vendor",
757 "anthropic",
758 "--cache-dir",
759 "/tmp/acct-a",
760 "--creds-path",
761 "/tmp/acct-a/credentials.json",
762 ]);
763 assert_eq!(
764 cli.cache_dir.as_deref(),
765 Some(std::path::Path::new("/tmp/acct-a"))
766 );
767 assert_eq!(
768 cli.creds_path.as_deref(),
769 Some(std::path::Path::new("/tmp/acct-a/credentials.json"))
770 );
771 }
772
773 #[test]
774 fn primary_from_config_wins_when_vendor_unset() {
775 let cli = Cli::parse_from(["ai-usagebar"]);
777 let mut cfg = crate::config::Config::default();
778 cfg.ui.primary = Some(crate::vendor::VendorId::Openrouter);
779 assert_eq!(cli.resolve_vendor_with(&cfg, None), Vendor::Openrouter);
780 }
781
782 #[test]
783 fn explicit_vendor_overrides_everything() {
784 let cli = Cli::parse_from(["ai-usagebar", "--vendor", "zai"]);
787 let mut cfg = crate::config::Config::default();
788 cfg.ui.primary = Some(crate::vendor::VendorId::Openrouter);
789 let active = Some(crate::vendor::VendorId::Openai);
790 assert_eq!(cli.resolve_vendor_with(&cfg, active), Vendor::Zai);
791 }
792
793 #[test]
794 fn vendor_kimi_parses_to_kimi_variant() {
795 let cli = Cli::parse_from(["ai-usagebar", "--vendor", "kimi"]);
796 assert_eq!(cli.vendor, Some(Vendor::Kimi));
797 assert_eq!(cli.vendor.unwrap().to_id(), crate::vendor::VendorId::Kimi);
798 }
799
800 #[test]
801 fn vendor_grokbot_parses_to_grokbot_variant() {
802 let cli = Cli::parse_from(["ai-usagebar", "--vendor", "grokbot"]);
803 assert_eq!(cli.vendor, Some(Vendor::Grokbot));
804 assert_eq!(
805 cli.vendor.unwrap().to_id(),
806 crate::vendor::VendorId::Grokbot
807 );
808 assert_eq!(
810 id_to_vendor(crate::vendor::VendorId::Grokbot),
811 Vendor::Grokbot
812 );
813 }
814
815 #[test]
816 fn vendor_modelstudio_parses_to_modelstudio_variant() {
817 let cli = Cli::parse_from(["ai-usagebar", "--vendor", "modelstudio"]);
818 assert_eq!(cli.vendor, Some(Vendor::ModelStudio));
819 assert_eq!(
820 cli.vendor.unwrap().to_id(),
821 crate::vendor::VendorId::ModelStudio
822 );
823 assert_eq!(
824 id_to_vendor(crate::vendor::VendorId::ModelStudio),
825 Vendor::ModelStudio
826 );
827 }
828
829 #[test]
830 fn vendor_anthropic_api_uses_the_documented_slug() {
831 let cli = Cli::parse_from(["ai-usagebar", "--vendor", "anthropic_api"]);
832 assert_eq!(cli.vendor, Some(Vendor::AnthropicApi));
833 assert_eq!(
834 cli.vendor.unwrap().to_id(),
835 crate::vendor::VendorId::AnthropicApi
836 );
837 }
838
839 #[test]
840 fn disabled_kimi_primary_falls_back_to_an_enabled_vendor() {
841 let cli = Cli::parse_from(["ai-usagebar"]);
842 let mut cfg = crate::config::Config::default();
843 cfg.ui.primary = Some(crate::vendor::VendorId::Kimi);
844 assert_eq!(cli.resolve_vendor_with(&cfg, None), Vendor::Anthropic);
845 }
846
847 #[test]
848 fn explicit_kimi_remains_an_opt_in_override_when_disabled() {
849 let cli = Cli::parse_from(["ai-usagebar", "--vendor", "kimi"]);
850 assert_eq!(
851 cli.resolve_vendor_with(&crate::config::Config::default(), None),
852 Vendor::Kimi
853 );
854 }
855
856 #[test]
857 fn active_override_wins_over_config_primary_when_enabled() {
858 let cli = Cli::parse_from(["ai-usagebar"]);
861 let mut cfg = crate::config::Config::default();
862 cfg.ui.primary = Some(crate::vendor::VendorId::Openrouter);
863 let active = Some(crate::vendor::VendorId::Zai);
864 assert_eq!(cli.resolve_vendor_with(&cfg, active), Vendor::Zai);
865 }
866
867 #[test]
868 fn disabled_active_override_falls_back_to_config_primary() {
869 let cli = Cli::parse_from(["ai-usagebar"]);
872 let mut cfg = crate::config::Config::default();
873 cfg.zai.enabled = false;
874 cfg.ui.primary = Some(crate::vendor::VendorId::Openrouter);
875 let active = Some(crate::vendor::VendorId::Zai);
876 assert_eq!(cli.resolve_vendor_with(&cfg, active), Vendor::Openrouter);
877 }
878
879 #[test]
880 fn claudebar_compatible_flag_surface() {
881 let cli = Cli::parse_from([
882 "ai-usagebar",
883 "--icon",
884 "",
885 "--format",
886 "{session_pct}% · {session_reset}",
887 "--tooltip-format",
888 "S:{session_pct}",
889 "--pace-tolerance",
890 "10",
891 "--format-pace-color",
892 "--tooltip-pace-pts",
893 "--color-low",
894 "#50fa7b",
895 "--color-mid",
896 "#f1fa8c",
897 "--color-high",
898 "#ffb86c",
899 "--color-critical",
900 "#ff5555",
901 ]);
902 assert_eq!(cli.icon.as_deref(), Some(""));
903 assert_eq!(
904 cli.format.as_deref(),
905 Some("{session_pct}% · {session_reset}")
906 );
907 assert_eq!(cli.tooltip_format.as_deref(), Some("S:{session_pct}"));
908 assert_eq!(cli.pace_tolerance, 10);
909 assert!(cli.format_pace_color);
910 assert!(cli.tooltip_pace_pts);
911 assert_eq!(cli.color_low.as_deref(), Some("#50fa7b"));
912 assert_eq!(cli.color_critical.as_deref(), Some("#ff5555"));
913 }
914
915 #[test]
916 fn pretty_and_json_conflict() {
917 let res = Cli::try_parse_from(["ai-usagebar", "--pretty", "--json"]);
918 assert!(res.is_err());
919 }
920
921 #[test]
922 fn watch_disables_json_output() {
923 let cli = Cli::parse_from(["ai-usagebar", "--watch", "5"]);
924 assert_eq!(cli.watch, Some(5));
925 assert!(!cli.output_json());
926 }
927}