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/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")]
131 pub account: Option<String>,
132
133 #[arg(long, requires = "account")]
138 pub desktop: bool,
139
140 #[command(subcommand)]
142 pub command: Option<Command>,
143}
144
145#[derive(clap::Subcommand, Debug, Clone)]
146pub enum Command {
147 Account {
149 #[command(subcommand)]
150 action: AccountAction,
151 },
152
153 Antigravity {
155 #[command(subcommand)]
156 action: AntigravityAction,
157 },
158
159 Usage {
166 #[arg(long)]
168 json: bool,
169 },
170
171 Detect {
174 #[arg(long)]
176 all: bool,
177 #[arg(long)]
179 json: bool,
180 },
181
182 Vendors {
187 #[arg(long)]
189 json: bool,
190 },
191
192 Settings {
194 #[command(subcommand)]
195 action: SettingsAction,
196 },
197
198 Auth {
200 #[command(subcommand)]
201 provider: AuthProvider,
202 },
203}
204
205#[derive(clap::Subcommand, Debug, Clone, Copy, PartialEq, Eq)]
206pub enum AntigravityAction {
207 SetupStatusline,
209 RemoveStatusline,
211 IngestStatusline,
213}
214
215#[derive(clap::Subcommand, Debug, Clone)]
216pub enum AuthProvider {
217 Nous {
218 #[command(subcommand)]
219 action: NousAuthAction,
220 },
221}
222
223#[derive(clap::Subcommand, Debug, Clone)]
224pub enum NousAuthAction {
225 Login,
227 Logout,
229}
230
231#[derive(clap::Subcommand, Debug, Clone)]
232pub enum SettingsAction {
233 Enable {
235 #[arg(value_enum)]
236 vendor: Vendor,
237 },
238
239 Show,
241
242 Apply,
244}
245
246#[derive(clap::Subcommand, Debug, Clone)]
247pub enum AccountAction {
248 Add {
250 label: String,
252
253 #[arg(long, conflicts_with = "desktop")]
255 no_login: bool,
256
257 #[arg(long)]
262 desktop: bool,
263
264 #[arg(long, requires = "desktop")]
267 email: Option<String>,
268
269 #[arg(short = 'y', long, requires = "desktop")]
271 yes: bool,
272
273 #[arg(long, conflicts_with = "desktop")]
277 codex: bool,
278
279 #[arg(long, conflicts_with_all = ["desktop", "no_login"])]
283 adopt_current: bool,
284 },
285
286 Status {
289 #[arg(long)]
291 json: bool,
292 },
293
294 Switch {
297 label: String,
301
302 #[arg(
305 long,
306 conflicts_with_all = ["desktop", "cli", "keep_bridge", "backup_sessions", "delete_conflict"]
307 )]
308 codex: bool,
309
310 #[arg(long)]
312 desktop: bool,
313
314 #[arg(long)]
316 cli: bool,
317
318 #[arg(long)]
320 dry_run: bool,
321
322 #[arg(short = 'y', long)]
324 yes: bool,
325
326 #[arg(long)]
330 force: bool,
331
332 #[arg(long)]
335 keep_bridge: bool,
336
337 #[arg(long)]
340 backup_sessions: bool,
341
342 #[arg(long, default_value_t = 10)]
344 keep_backups: usize,
345
346 #[arg(long, value_name = "KEY")]
353 delete_conflict: Vec<String>,
354 },
355
356 MergeHistory {
371 #[arg(long, value_name = "DIR")]
375 data_dir: std::path::PathBuf,
376
377 #[arg(long, value_name = "DIR")]
384 from: Vec<std::path::PathBuf>,
385
386 #[arg(long)]
388 dry_run: bool,
389 },
390}
391
392#[derive(Debug, Clone, Copy, ValueEnum, PartialEq, Eq)]
393pub enum Vendor {
394 Anthropic,
395 #[value(name = "anthropic_api")]
396 AnthropicApi,
397 Openai,
398 Copilot,
399 Zai,
400 Openrouter,
401 Deepseek,
402 Deepinfra,
403 Kimi,
404 Kilo,
405 Novita,
406 Moonshot,
407 Grok,
408 Supergrok,
409 Grokbot,
410 Antigravity,
411 Cursor,
412 Minimax,
413 Kiro,
414 #[value(name = "nous")]
415 NousResearch,
416 #[value(name = "opencode-go")]
417 OpenCodeGo,
418 #[value(name = "commandcode")]
419 CommandCode,
420 Ollama,
421 #[value(name = "orcarouter", alias = "orca-router")]
424 OrcaRouter,
425 #[value(name = "modelstudio")]
426 ModelStudio,
427 Lyceum,
428 Devin,
429 MuseCode,
430 Cline,
431}
432
433impl Vendor {
434 pub fn to_id(self) -> crate::vendor::VendorId {
435 match self {
436 Vendor::Anthropic => crate::vendor::VendorId::Anthropic,
437 Vendor::AnthropicApi => crate::vendor::VendorId::AnthropicApi,
438 Vendor::Openai => crate::vendor::VendorId::Openai,
439 Vendor::Copilot => crate::vendor::VendorId::Copilot,
440 Vendor::Zai => crate::vendor::VendorId::Zai,
441 Vendor::Openrouter => crate::vendor::VendorId::Openrouter,
442 Vendor::Deepseek => crate::vendor::VendorId::Deepseek,
443 Vendor::Deepinfra => crate::vendor::VendorId::Deepinfra,
444 Vendor::Kimi => crate::vendor::VendorId::Kimi,
445 Vendor::Kilo => crate::vendor::VendorId::Kilo,
446 Vendor::Novita => crate::vendor::VendorId::Novita,
447 Vendor::Moonshot => crate::vendor::VendorId::Moonshot,
448 Vendor::Grok => crate::vendor::VendorId::Grok,
449 Vendor::Supergrok => crate::vendor::VendorId::Supergrok,
450 Vendor::Grokbot => crate::vendor::VendorId::Grokbot,
451 Vendor::Antigravity => crate::vendor::VendorId::Antigravity,
452 Vendor::Cursor => crate::vendor::VendorId::Cursor,
453 Vendor::Minimax => crate::vendor::VendorId::Minimax,
454 Vendor::Kiro => crate::vendor::VendorId::Kiro,
455 Vendor::NousResearch => crate::vendor::VendorId::NousResearch,
456 Vendor::OpenCodeGo => crate::vendor::VendorId::OpenCodeGo,
457 Vendor::CommandCode => crate::vendor::VendorId::CommandCode,
458 Vendor::Ollama => crate::vendor::VendorId::Ollama,
459 Vendor::OrcaRouter => crate::vendor::VendorId::OrcaRouter,
460 Vendor::ModelStudio => crate::vendor::VendorId::ModelStudio,
461 Vendor::Lyceum => crate::vendor::VendorId::Lyceum,
462 Vendor::Devin => crate::vendor::VendorId::Devin,
463 Vendor::MuseCode => crate::vendor::VendorId::MuseCode,
464 Vendor::Cline => crate::vendor::VendorId::Cline,
465 }
466 }
467}
468
469impl Cli {
470 pub fn has_explicit_vendor(&self) -> bool {
472 self.vendor.is_some()
473 }
474
475 pub fn resolved_vendor(&self, config: &crate::config::Config) -> Vendor {
486 let active = if self.has_explicit_vendor() {
492 None
493 } else {
494 crate::active::read()
495 };
496 self.resolve_vendor_with(config, active)
497 }
498
499 pub fn resolve_vendor_with(
504 &self,
505 config: &crate::config::Config,
506 active: Option<crate::vendor::VendorId>,
507 ) -> Vendor {
508 if let Some(v) = self.vendor {
509 return v;
510 }
511 if let Some(id) = active
512 && config.is_enabled(id)
513 {
514 return id_to_vendor(id);
515 }
516 if let Some(id) = config.ui.primary
517 && config.is_enabled(id)
518 {
519 return id_to_vendor(id);
520 }
521 if config.is_enabled(crate::vendor::VendorId::Anthropic) {
522 return Vendor::Anthropic;
523 }
524 config
525 .enabled_vendors()
526 .into_iter()
527 .next()
528 .map(id_to_vendor)
529 .unwrap_or(Vendor::Anthropic)
532 }
533}
534
535fn id_to_vendor(id: crate::vendor::VendorId) -> Vendor {
536 match id {
537 crate::vendor::VendorId::Anthropic => Vendor::Anthropic,
538 crate::vendor::VendorId::AnthropicApi => Vendor::AnthropicApi,
539 crate::vendor::VendorId::Openai => Vendor::Openai,
540 crate::vendor::VendorId::Copilot => Vendor::Copilot,
541 crate::vendor::VendorId::Zai => Vendor::Zai,
542 crate::vendor::VendorId::Openrouter => Vendor::Openrouter,
543 crate::vendor::VendorId::Deepseek => Vendor::Deepseek,
544 crate::vendor::VendorId::Deepinfra => Vendor::Deepinfra,
545 crate::vendor::VendorId::Kimi => Vendor::Kimi,
546 crate::vendor::VendorId::Kilo => Vendor::Kilo,
547 crate::vendor::VendorId::Novita => Vendor::Novita,
548 crate::vendor::VendorId::Moonshot => Vendor::Moonshot,
549 crate::vendor::VendorId::Grok => Vendor::Grok,
550 crate::vendor::VendorId::Supergrok => Vendor::Supergrok,
551 crate::vendor::VendorId::Grokbot => Vendor::Grokbot,
552 crate::vendor::VendorId::Antigravity => Vendor::Antigravity,
553 crate::vendor::VendorId::Cursor => Vendor::Cursor,
554 crate::vendor::VendorId::Minimax => Vendor::Minimax,
555 crate::vendor::VendorId::Kiro => Vendor::Kiro,
556 crate::vendor::VendorId::NousResearch => Vendor::NousResearch,
557 crate::vendor::VendorId::OpenCodeGo => Vendor::OpenCodeGo,
558 crate::vendor::VendorId::CommandCode => Vendor::CommandCode,
559 crate::vendor::VendorId::Ollama => Vendor::Ollama,
560 crate::vendor::VendorId::OrcaRouter => Vendor::OrcaRouter,
561 crate::vendor::VendorId::ModelStudio => Vendor::ModelStudio,
562 crate::vendor::VendorId::Lyceum => Vendor::Lyceum,
563 crate::vendor::VendorId::Devin => Vendor::Devin,
564 crate::vendor::VendorId::MuseCode => Vendor::MuseCode,
565 crate::vendor::VendorId::Cline => Vendor::Cline,
566 }
567}
568
569impl Cli {
570 pub fn output_json(&self) -> bool {
573 if self.json {
574 return true;
575 }
576 if self.pretty || self.watch.is_some() {
577 return false;
578 }
579 !is_stdout_tty()
581 }
582}
583
584fn is_stdout_tty() -> bool {
585 use std::io::IsTerminal;
586 std::io::stdout().is_terminal()
587}
588
589#[cfg(test)]
590mod tests {
591 use super::*;
592 use clap::{Parser, error::ErrorKind};
593
594 #[test]
595 fn settings_enable_requires_a_known_vendor() {
596 assert!(matches!(
597 Cli::try_parse_from(["ai-usagebar", "settings", "enable", "anthropic"])
598 .unwrap()
599 .command,
600 Some(Command::Settings {
601 action: SettingsAction::Enable {
602 vendor: Vendor::Anthropic
603 }
604 })
605 ));
606 assert!(Cli::try_parse_from(["ai-usagebar", "settings", "enable", "unknown"]).is_err());
607 assert!(Cli::try_parse_from(["ai-usagebar", "settings", "enable"]).is_err());
608 }
609
610 #[test]
611 fn antigravity_statusline_actions_parse_and_require_an_action() {
612 for (arguments, expected) in [
613 (&["antigravity", "setup-statusline"][..], "setup"),
614 (&["antigravity", "remove-statusline"][..], "remove"),
615 (&["antigravity", "ingest-statusline"][..], "ingest"),
616 ] {
617 let cli = Cli::try_parse_from(
618 std::iter::once("ai-usagebar").chain(arguments.iter().copied()),
619 )
620 .unwrap();
621 assert!(
622 matches!(cli.command, Some(Command::Antigravity { .. })),
623 "{expected}"
624 );
625 }
626 assert!(Cli::try_parse_from(["ai-usagebar", "antigravity"]).is_err());
627 }
628
629 #[test]
630 fn version_flags_report_the_crate_version() {
631 let expected = format!("ai-usagebar {}\n", env!("CARGO_PKG_VERSION"));
632
633 for flag in ["--version", "-V"] {
634 let err = Cli::try_parse_from(["ai-usagebar", flag])
635 .expect_err("a version flag exits through clap's display path");
636 assert_eq!(err.kind(), ErrorKind::DisplayVersion, "flag: {flag}");
637 assert_eq!(err.to_string(), expected, "flag: {flag}");
638 }
639 }
640
641 #[test]
642 fn usage_subcommand_parses_machine_readable_mode() {
643 let cli = Cli::parse_from(["ai-usagebar", "usage", "--json"]);
644 assert!(matches!(cli.command, Some(Command::Usage { json: true })));
645 }
646
647 #[test]
648 fn usage_help_states_complete_document_exits_zero() {
649 let err = Cli::try_parse_from(["ai-usagebar", "usage", "--help"])
650 .expect_err("help exits through clap's display path");
651 assert_eq!(err.kind(), ErrorKind::DisplayHelp);
652 let help = err.to_string();
653 assert!(
654 help.contains("Exits 0 after printing a complete document"),
655 "{help}"
656 );
657 assert!(
658 help.contains("even when every entry") && help.contains("error"),
659 "{help}"
660 );
661 assert!(
662 help.contains("Non-zero only when the document cannot be produced"),
663 "{help}"
664 );
665 }
666
667 #[test]
668 fn detect_subcommand_parses_its_flags_and_takes_no_widget_flags() {
669 let bare = Cli::parse_from(["ai-usagebar", "detect"]);
670 assert!(matches!(
671 bare.command,
672 Some(Command::Detect {
673 all: false,
674 json: false
675 })
676 ));
677
678 let full = Cli::parse_from(["ai-usagebar", "detect", "--all", "--json"]);
679 assert!(matches!(
680 full.command,
681 Some(Command::Detect {
682 all: true,
683 json: true
684 })
685 ));
686
687 assert!(Cli::try_parse_from(["ai-usagebar", "--vendor", "kimi", "detect"]).is_err());
688 }
689
690 #[test]
691 fn new_vendor_values_and_auth_commands_parse_exactly() {
692 let nous = Cli::parse_from(["ai-usagebar", "--vendor", "nous"]);
693 assert_eq!(nous.vendor, Some(Vendor::NousResearch));
694 let opencode = Cli::parse_from(["ai-usagebar", "--vendor", "opencode-go"]);
695 assert_eq!(opencode.vendor, Some(Vendor::OpenCodeGo));
696 let copilot = Cli::parse_from(["ai-usagebar", "--vendor", "copilot"]);
697 assert_eq!(copilot.vendor, Some(Vendor::Copilot));
698 let login = Cli::parse_from(["ai-usagebar", "auth", "nous", "login"]);
699 assert!(matches!(login.command, Some(Command::Auth { .. })));
700 }
701
702 #[test]
703 fn settings_subcommands_are_additive_and_take_no_widget_flags() {
704 let show = Cli::parse_from(["ai-usagebar", "settings", "show"]);
705 assert!(matches!(
706 show.command,
707 Some(Command::Settings {
708 action: SettingsAction::Show
709 })
710 ));
711
712 let apply = Cli::parse_from(["ai-usagebar", "settings", "apply"]);
713 assert!(matches!(
714 apply.command,
715 Some(Command::Settings {
716 action: SettingsAction::Apply
717 })
718 ));
719
720 assert!(
721 Cli::try_parse_from(["ai-usagebar", "--vendor", "kimi", "settings", "show",]).is_err()
722 );
723 }
724
725 #[test]
726 fn defaults_match_claudebar() {
727 let cli = Cli::parse_from(["ai-usagebar"]);
728 assert_eq!(cli.vendor, None);
729 let cfg = crate::config::Config::default();
734 assert_eq!(cli.resolve_vendor_with(&cfg, None), Vendor::Anthropic);
735 assert_eq!(cli.pace_tolerance, 5);
736 assert!(cli.format.is_none());
737 assert!(cli.tooltip_format.is_none());
738 assert!(cli.icon.is_none());
739 assert!(!cli.format_pace_color);
740 assert!(!cli.tooltip_pace_pts);
741 assert!(!cli.pretty);
742 assert!(!cli.json);
743 assert!(cli.watch.is_none());
744 assert!(cli.command.is_none());
745 }
746
747 #[test]
748 fn account_add_subcommand_parses_without_widget_flags() {
749 let cli = Cli::parse_from(["ai-usagebar", "account", "add", "work", "--no-login"]);
750 assert!(matches!(
751 cli.command,
752 Some(Command::Account {
753 action: AccountAction::Add {
754 ref label,
755 no_login: true,
756 desktop: false,
757 ..
758 }
759 }) if label == "work"
760 ));
761 }
762
763 #[test]
766 fn account_add_desktop_takes_an_email_and_rejects_no_login() {
767 let cli = Cli::parse_from([
768 "ai-usagebar",
769 "account",
770 "add",
771 "work",
772 "--desktop",
773 "--email",
774 "a@b.test",
775 "-y",
776 ]);
777 assert!(matches!(
778 cli.command,
779 Some(Command::Account {
780 action: AccountAction::Add {
781 desktop: true,
782 yes: true,
783 email: Some(ref email),
784 ..
785 }
786 }) if email == "a@b.test"
787 ));
788 assert!(
789 Cli::try_parse_from([
790 "ai-usagebar",
791 "account",
792 "add",
793 "w",
794 "--desktop",
795 "--no-login"
796 ])
797 .is_err()
798 );
799 assert!(
801 Cli::try_parse_from(["ai-usagebar", "account", "add", "w", "--email", "a@b.test"])
802 .is_err()
803 );
804 }
805
806 #[test]
807 fn account_switch_defaults_to_both_surfaces() {
808 let cli = Cli::parse_from(["ai-usagebar", "account", "switch", "work", "--dry-run"]);
809 assert!(matches!(
810 cli.command,
811 Some(Command::Account {
812 action: AccountAction::Switch {
813 ref label,
814 desktop: false,
815 cli: false,
816 dry_run: true,
817 keep_backups: 10,
818 ..
819 }
820 }) if label == "work"
821 ));
822 }
823
824 #[test]
825 fn account_subcommand_rejects_ignored_widget_flags() {
826 assert!(
827 Cli::try_parse_from([
828 "ai-usagebar",
829 "--vendor",
830 "anthropic",
831 "account",
832 "add",
833 "work",
834 ])
835 .is_err()
836 );
837 }
838
839 #[test]
840 fn multi_account_flags_are_stable_api() {
841 let cli = Cli::parse_from([
845 "ai-usagebar",
846 "--vendor",
847 "anthropic",
848 "--cache-dir",
849 "/tmp/acct-a",
850 "--creds-path",
851 "/tmp/acct-a/credentials.json",
852 ]);
853 assert_eq!(
854 cli.cache_dir.as_deref(),
855 Some(std::path::Path::new("/tmp/acct-a"))
856 );
857 assert_eq!(
858 cli.creds_path.as_deref(),
859 Some(std::path::Path::new("/tmp/acct-a/credentials.json"))
860 );
861 }
862
863 #[test]
864 fn primary_from_config_wins_when_vendor_unset() {
865 let cli = Cli::parse_from(["ai-usagebar"]);
867 let mut cfg = crate::config::Config::default();
868 cfg.ui.primary = Some(crate::vendor::VendorId::Openrouter);
869 assert_eq!(cli.resolve_vendor_with(&cfg, None), Vendor::Openrouter);
870 }
871
872 #[test]
873 fn explicit_vendor_overrides_everything() {
874 let cli = Cli::parse_from(["ai-usagebar", "--vendor", "zai"]);
877 let mut cfg = crate::config::Config::default();
878 cfg.ui.primary = Some(crate::vendor::VendorId::Openrouter);
879 let active = Some(crate::vendor::VendorId::Openai);
880 assert_eq!(cli.resolve_vendor_with(&cfg, active), Vendor::Zai);
881 }
882
883 #[test]
884 fn vendor_kimi_parses_to_kimi_variant() {
885 let cli = Cli::parse_from(["ai-usagebar", "--vendor", "kimi"]);
886 assert_eq!(cli.vendor, Some(Vendor::Kimi));
887 assert_eq!(cli.vendor.unwrap().to_id(), crate::vendor::VendorId::Kimi);
888 }
889
890 #[test]
891 fn vendor_grokbot_parses_to_grokbot_variant() {
892 let cli = Cli::parse_from(["ai-usagebar", "--vendor", "grokbot"]);
893 assert_eq!(cli.vendor, Some(Vendor::Grokbot));
894 assert_eq!(
895 cli.vendor.unwrap().to_id(),
896 crate::vendor::VendorId::Grokbot
897 );
898 assert_eq!(
900 id_to_vendor(crate::vendor::VendorId::Grokbot),
901 Vendor::Grokbot
902 );
903 }
904
905 #[test]
906 fn vendor_modelstudio_parses_to_modelstudio_variant() {
907 let cli = Cli::parse_from(["ai-usagebar", "--vendor", "modelstudio"]);
908 assert_eq!(cli.vendor, Some(Vendor::ModelStudio));
909 assert_eq!(
910 cli.vendor.unwrap().to_id(),
911 crate::vendor::VendorId::ModelStudio
912 );
913 assert_eq!(
914 id_to_vendor(crate::vendor::VendorId::ModelStudio),
915 Vendor::ModelStudio
916 );
917 }
918
919 #[test]
920 fn vendor_anthropic_api_uses_the_documented_slug() {
921 let cli = Cli::parse_from(["ai-usagebar", "--vendor", "anthropic_api"]);
922 assert_eq!(cli.vendor, Some(Vendor::AnthropicApi));
923 assert_eq!(
924 cli.vendor.unwrap().to_id(),
925 crate::vendor::VendorId::AnthropicApi
926 );
927 }
928
929 #[test]
930 fn every_vendor_parses_from_its_slug() {
931 for vendor in <Vendor as clap::ValueEnum>::value_variants() {
934 let slug = vendor.to_id().slug();
935 let cli = Cli::parse_from(["ai-usagebar", "--vendor", slug]);
936 assert_eq!(cli.vendor, Some(*vendor), "{slug}");
937 }
938 let legacy = Cli::parse_from(["ai-usagebar", "--vendor", "orca-router"]);
939 assert_eq!(legacy.vendor, Some(Vendor::OrcaRouter));
940 }
941
942 #[test]
943 fn disabled_kimi_primary_falls_back_to_an_enabled_vendor() {
944 let cli = Cli::parse_from(["ai-usagebar"]);
945 let mut cfg = crate::config::Config::default();
946 cfg.ui.primary = Some(crate::vendor::VendorId::Kimi);
947 assert_eq!(cli.resolve_vendor_with(&cfg, None), Vendor::Anthropic);
948 }
949
950 #[test]
951 fn explicit_kimi_remains_an_opt_in_override_when_disabled() {
952 let cli = Cli::parse_from(["ai-usagebar", "--vendor", "kimi"]);
953 assert_eq!(
954 cli.resolve_vendor_with(&crate::config::Config::default(), None),
955 Vendor::Kimi
956 );
957 }
958
959 #[test]
960 fn active_override_wins_over_config_primary_when_enabled() {
961 let cli = Cli::parse_from(["ai-usagebar"]);
964 let mut cfg = crate::config::Config::default();
965 cfg.ui.primary = Some(crate::vendor::VendorId::Openrouter);
966 let active = Some(crate::vendor::VendorId::Zai);
967 assert_eq!(cli.resolve_vendor_with(&cfg, active), Vendor::Zai);
968 }
969
970 #[test]
971 fn disabled_active_override_falls_back_to_config_primary() {
972 let cli = Cli::parse_from(["ai-usagebar"]);
975 let mut cfg = crate::config::Config::default();
976 cfg.zai.enabled = false;
977 cfg.ui.primary = Some(crate::vendor::VendorId::Openrouter);
978 let active = Some(crate::vendor::VendorId::Zai);
979 assert_eq!(cli.resolve_vendor_with(&cfg, active), Vendor::Openrouter);
980 }
981
982 #[test]
983 fn claudebar_compatible_flag_surface() {
984 let cli = Cli::parse_from([
985 "ai-usagebar",
986 "--icon",
987 "",
988 "--format",
989 "{session_pct}% · {session_reset}",
990 "--tooltip-format",
991 "S:{session_pct}",
992 "--pace-tolerance",
993 "10",
994 "--format-pace-color",
995 "--tooltip-pace-pts",
996 "--color-low",
997 "#50fa7b",
998 "--color-mid",
999 "#f1fa8c",
1000 "--color-high",
1001 "#ffb86c",
1002 "--color-critical",
1003 "#ff5555",
1004 ]);
1005 assert_eq!(cli.icon.as_deref(), Some(""));
1006 assert_eq!(
1007 cli.format.as_deref(),
1008 Some("{session_pct}% · {session_reset}")
1009 );
1010 assert_eq!(cli.tooltip_format.as_deref(), Some("S:{session_pct}"));
1011 assert_eq!(cli.pace_tolerance, 10);
1012 assert!(cli.format_pace_color);
1013 assert!(cli.tooltip_pace_pts);
1014 assert_eq!(cli.color_low.as_deref(), Some("#50fa7b"));
1015 assert_eq!(cli.color_critical.as_deref(), Some("#ff5555"));
1016 }
1017
1018 #[test]
1019 fn pretty_and_json_conflict() {
1020 let res = Cli::try_parse_from(["ai-usagebar", "--pretty", "--json"]);
1021 assert!(res.is_err());
1022 }
1023
1024 #[test]
1025 fn watch_disables_json_output() {
1026 let cli = Cli::parse_from(["ai-usagebar", "--watch", "5"]);
1027 assert_eq!(cli.watch, Some(5));
1028 assert!(!cli.output_json());
1029 }
1030}