1use std::panic::{AssertUnwindSafe, catch_unwind};
27use std::path::{Path, PathBuf};
28
29use serde::{Deserialize, Serialize};
30
31use crate::config::Config;
32use crate::error::{AppError, Result};
33use crate::vendor::VendorId;
34
35pub fn has_local_credentials(vendor: VendorId, config: &Config) -> bool {
42 match vendor {
43 VendorId::Anthropic => anthropic_present(config),
44 VendorId::AnthropicApi => key_present(config, vendor),
45 VendorId::Openai => {
46 config
47 .openai
48 .resolve_auth_path(None)
49 .is_ok_and(|path| crate::openai::creds::read_from(&path).is_ok())
50 || config.openai.accounts.iter().any(|account| {
51 crate::openai::creds::read_from(&account.codex_auth_path).is_ok()
52 })
53 }
54 VendorId::Copilot => copilot_present(),
55 VendorId::Zai => key_present(config, vendor),
56 VendorId::Openrouter => key_present(config, vendor),
57 VendorId::Deepseek => key_present(config, vendor),
58 VendorId::Deepinfra => key_present(config, vendor),
59 VendorId::Kimi => crate::kimi::resolve_auth(&config.kimi).is_ok(),
60 VendorId::Kilo => key_present(config, vendor),
61 VendorId::Novita => key_present(config, vendor),
62 VendorId::Synthetic => key_present(config, vendor),
63 VendorId::Moonshot => key_present(config, vendor),
64 VendorId::Grok => key_present(config, vendor),
65 VendorId::Supergrok => crate::supergrok::scope::ScopePaths::with_overrides(
66 config.supergrok.auth_path.as_deref(),
67 config.supergrok.config_path.as_deref(),
68 )
69 .is_ok_and(|paths| crate::supergrok::direct::read_billing_key(&paths.auth).is_ok()),
70 VendorId::Grokbot => crate::grokbot::secrets_path(&config.grokbot)
74 .map(|path| crate::grokbot::creds::secrets_present_at(&path))
75 .unwrap_or(false),
76 VendorId::Antigravity => antigravity_present(),
77 VendorId::Cursor => cursor_present(config),
78 VendorId::Minimax => key_present(config, vendor),
79 VendorId::Kiro => {
80 let path = match config.kiro.db_path.clone() {
81 Some(path) => path,
82 None => match crate::kiro::db::default_db_path() {
83 Ok(path) => path,
84 Err(_) => return false,
85 },
86 };
87 crate::kiro::db::read_credentials(&path).is_ok()
88 }
89 VendorId::NousResearch => {
90 let store = crate::nous::credentials::CredentialStore::at(
94 crate::nous::credentials::default_credentials_path(),
95 );
96 matches!(store.read_unlocked(), Ok(Some(_)))
97 }
98 VendorId::OpenCodeGo => {
102 key_present(config, vendor)
103 || crate::opencode_go::creds::probe(config.opencode_go.auth_paths.as_deref())
104 }
105 VendorId::CommandCode => {
106 crate::commandcode::creds::resolve(config.commandcode.auth_paths.as_deref()).is_ok()
107 }
108 VendorId::Cline => crate::cline::creds::resolve(
109 config.cline.auth_paths.as_deref(),
110 &config.cline.api_key_env,
111 config.cline.api_key.as_deref(),
112 )
113 .is_ok(),
114 VendorId::Ollama => key_present(config, vendor),
115 VendorId::OrcaRouter => key_present(config, vendor),
116 VendorId::Lyceum => key_present(config, vendor),
117 VendorId::ModelStudio => crate::modelstudio::config_path(&config.modelstudio)
120 .map(|path| crate::modelstudio::creds::config_present_at(&path))
121 .unwrap_or(false),
122 VendorId::MuseCode => crate::muse::creds::path(&config.muse)
123 .map(|path| path.is_file())
124 .unwrap_or(false),
125 VendorId::Devin => crate::devin::credentials_path(&config.devin)
126 .map(|path| path.is_file())
127 .unwrap_or(false),
128 }
129}
130
131fn key_present(config: &Config, vendor: VendorId) -> bool {
138 crate::config::optional_api_key(
139 config.api_key_env_for(vendor),
140 config.inline_api_key(vendor),
141 )
142 .is_some()
143 || config
144 .api_key_accounts(vendor)
145 .unwrap_or(&[])
146 .iter()
147 .any(|account| {
148 crate::config::optional_api_key(
149 account.api_key_env.as_deref().unwrap_or(""),
150 account.api_key.as_deref(),
151 )
152 .is_some()
153 })
154}
155
156fn anthropic_present(config: &Config) -> bool {
161 use crate::anthropic::creds::{CredsTarget, default_path, resolve};
162 let target = match config.anthropic.credentials_path.clone() {
163 Some(path) => CredsTarget::Explicit(path),
164 None => match default_path() {
165 Ok(path) => CredsTarget::Default(path),
166 Err(_) => return false,
167 },
168 };
169 resolve(&target).is_ok()
170 || config.anthropic.all_accounts().iter().any(|account| {
171 resolve(&CredsTarget::Named {
172 path: account.credentials_path.clone(),
173 config_dir: account.config_dir(),
174 })
175 .is_ok()
176 })
177}
178
179fn copilot_present() -> bool {
189 if std::env::var_os("GITHUB_COPILOT_TOKEN").is_some_and(|value| !value.is_empty()) {
190 return true;
191 }
192 crate::copilot::credentials::default_hosts_path()
193 .is_ok_and(|path| copilot_hosts_present_at(&path))
194}
195
196pub(crate) fn copilot_hosts_present_at(path: &Path) -> bool {
199 std::fs::metadata(path).is_ok_and(|meta| meta.is_file() && meta.len() > 0)
200}
201
202fn antigravity_present() -> bool {
206 if std::env::var_os("ANTIGRAVITY_LS_ADDRESS").is_some_and(|value| !value.is_empty()) {
207 return true;
208 }
209 if !crate::antigravity::fetch::discover_ls_ports().is_empty() {
210 return true;
211 }
212 crate::cache::Cache::for_vendor(crate::vendor::VendorId::Antigravity.slug()).is_ok_and(
219 |cache| {
220 crate::antigravity::cloud::has_persisted_session(
221 &crate::antigravity::cloud::oauth_cache_path(&cache),
222 )
223 },
224 )
225}
226
227fn cursor_present(config: &Config) -> bool {
228 let db_path = match config.cursor.db_path.clone() {
229 Some(path) => path,
230 None => match crate::cursor::db::default_db_path() {
231 Ok(path) => path,
232 Err(_) => return false,
233 },
234 };
235 let agent_auth_path = match config.cursor.agent_auth_path.clone() {
236 Some(path) => path,
237 None => match crate::cursor::db::default_agent_auth_path() {
238 Ok(path) => path,
239 Err(_) => return false,
240 },
241 };
242 crate::cursor::db::resolve_access_token(&db_path, &agent_auth_path).is_ok()
243}
244
245#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
249pub struct DetectState {
250 #[serde(default)]
251 pub known: Vec<VendorId>,
252}
253
254impl DetectState {
255 pub fn load_at(path: &Path) -> DetectState {
259 std::fs::read(path)
260 .ok()
261 .and_then(|bytes| serde_json::from_slice(&bytes).ok())
262 .unwrap_or_default()
263 }
264
265 pub fn save_at(&self, path: &Path) -> Result<()> {
267 let bytes = serde_json::to_vec_pretty(self)?;
268 crate::cache::atomic_write(path, &bytes)
269 }
270}
271
272pub fn default_state_path() -> Result<PathBuf> {
275 Ok(crate::cache::xdg_cache_dir()?
276 .join("ai-usagebar")
277 .join("detect.json"))
278}
279
280#[derive(Debug, Default, Clone, PartialEq, Eq)]
282pub struct DetectPlan {
283 pub enable: Vec<VendorId>,
285 pub known: Vec<VendorId>,
287 pub probed: usize,
290}
291
292#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize)]
295pub struct DetectReport {
296 pub enabled: Vec<VendorId>,
298 pub known: Vec<VendorId>,
300 pub probed: usize,
302}
303
304pub fn plan(
313 config: &Config,
314 state: &DetectState,
315 all: &[VendorId],
316 force: bool,
317 probe: impl Fn(VendorId) -> bool,
318) -> DetectPlan {
319 let candidates: Vec<VendorId> = all
320 .iter()
321 .copied()
322 .filter(|vendor| vendor.auto_detectable())
323 .filter(|vendor| force || !state.known.contains(vendor))
324 .collect();
325 let probed = candidates.len();
326 let enable = candidates
327 .into_iter()
328 .filter(|vendor| !config.is_enabled(*vendor))
329 .filter(|vendor| probe(*vendor))
330 .collect();
331 let known = VendorId::all()
332 .iter()
333 .copied()
334 .filter(|vendor| state.known.contains(vendor) || all.contains(vendor))
335 .collect();
336 DetectPlan {
337 enable,
338 known,
339 probed,
340 }
341}
342
343pub fn run_once(
352 config_path: Option<&Path>,
353 state_path: &Path,
354 force: bool,
355) -> Result<Vec<VendorId>> {
356 run_once_report(config_path, state_path, force).map(|report| report.enabled)
357}
358
359pub fn run_once_report(
362 config_path: Option<&Path>,
363 state_path: &Path,
364 force: bool,
365) -> Result<DetectReport> {
366 run_once_with(config_path, state_path, force, |vendor, config| {
367 catch_unwind(AssertUnwindSafe(|| has_local_credentials(vendor, config))).unwrap_or(false)
368 })
369}
370
371pub fn run_cli(all: bool, json: bool) -> i32 {
376 let report =
377 default_state_path().and_then(|state_path| run_once_report(None, &state_path, all));
378 match report {
379 Ok(report) if json => match serde_json::to_string(&report) {
380 Ok(text) => {
381 println!("{text}");
382 0
383 }
384 Err(error) => {
385 eprintln!("ai-usagebar detect: {error}");
386 1
387 }
388 },
389 Ok(report) => {
390 println!(
391 "{}",
392 format_report(&report, &crate::config::config_path_hint())
393 );
394 0
395 }
396 Err(error) => {
397 eprintln!("ai-usagebar detect: {}", error.user_message());
398 1
399 }
400 }
401}
402
403pub fn format_report(report: &DetectReport, config_hint: &str) -> String {
406 if report.enabled.is_empty() {
407 let noun = if report.probed == 1 {
408 "vendor"
409 } else {
410 "vendors"
411 };
412 return format!("Nothing new detected ({} {noun} checked)", report.probed);
413 }
414 let names: Vec<&str> = report
415 .enabled
416 .iter()
417 .map(|vendor| vendor.display_name())
418 .collect();
419 format!("Enabled: {}\nWritten to {config_hint}", names.join(", "))
420}
421
422pub fn run_once_with(
426 config_path: Option<&Path>,
427 state_path: &Path,
428 force: bool,
429 probe: impl Fn(VendorId, &Config) -> bool,
430) -> Result<DetectReport> {
431 let resolved = match config_path {
432 Some(path) => Some(path.to_path_buf()),
433 None => crate::config::resolved_path(),
434 };
435 let config = match &resolved {
436 Some(path) => Config::load_from(path)?,
437 None => Config::default(),
438 };
439 let state = DetectState::load_at(state_path);
440 let plan = plan(&config, &state, VendorId::all(), force, |vendor| {
441 probe(vendor, &config)
442 });
443 let enabled = if plan.enable.is_empty() {
447 Vec::new()
448 } else {
449 let path = resolved.ok_or_else(|| {
450 AppError::Other("could not resolve the config.toml path to enable vendors in".into())
451 })?;
452 crate::config::enable_vendors_in(&path, &plan.enable)?
453 };
454 DetectState {
455 known: plan.known.clone(),
456 }
457 .save_at(state_path)?;
458 Ok(DetectReport {
459 enabled,
460 known: plan.known,
461 probed: plan.probed,
462 })
463}
464
465#[cfg(test)]
466mod tests {
467 use super::*;
468 use tempfile::TempDir;
469
470 #[test]
471 fn lyceum_credential_detection_uses_inline_key_without_home_or_network() {
472 let mut config = Config::default();
473 config.lyceum.api_key_env.clear(); config.lyceum.api_key = Some("synthetic-key".into());
475 assert!(has_local_credentials(VendorId::Lyceum, &config));
476 config.lyceum.api_key = None;
477 assert!(!has_local_credentials(VendorId::Lyceum, &config));
478 }
479
480 fn detectable_count() -> usize {
482 VendorId::all()
483 .iter()
484 .filter(|vendor| vendor.auto_detectable())
485 .count()
486 }
487
488 fn probe_in(present: &[VendorId]) -> impl Fn(VendorId) -> bool + '_ {
489 move |vendor| present.contains(&vendor)
490 }
491
492 #[test]
493 fn state_round_trips_through_json_by_slug() {
494 let dir = TempDir::new().unwrap();
495 let path = dir.path().join("nested").join("detect.json");
496 let state = DetectState {
497 known: vec![
498 VendorId::Cursor,
499 VendorId::OpenCodeGo,
500 VendorId::NousResearch,
501 ],
502 };
503
504 state.save_at(&path).unwrap();
505
506 let text = std::fs::read_to_string(&path).unwrap();
507 assert!(text.contains("\"opencode-go\""), "{text}");
508 assert!(text.contains("\"nous\""), "{text}");
509 assert_eq!(DetectState::load_at(&path), state);
510 }
511
512 #[test]
513 fn missing_or_corrupt_state_is_the_default() {
514 let dir = TempDir::new().unwrap();
515 assert_eq!(
516 DetectState::load_at(&dir.path().join("absent.json")),
517 DetectState::default()
518 );
519
520 let corrupt = dir.path().join("corrupt.json");
521 std::fs::write(&corrupt, "{\"known\": [\"not-a-vendor\"").unwrap();
522 assert_eq!(DetectState::load_at(&corrupt), DetectState::default());
523
524 let unknown_slug = dir.path().join("unknown.json");
525 std::fs::write(&unknown_slug, "{\"known\": [\"not-a-vendor\"]}").unwrap();
526 assert_eq!(DetectState::load_at(&unknown_slug), DetectState::default());
527 }
528
529 #[test]
530 fn plan_enables_only_unknown_probed_vendors_that_are_off() {
531 let config = Config::default(); let state = DetectState {
533 known: vec![VendorId::Grok],
534 };
535 let all = [
536 VendorId::Anthropic, VendorId::Grok, VendorId::Cursor, VendorId::Kiro, ];
541 let present = [VendorId::Anthropic, VendorId::Grok, VendorId::Cursor];
542
543 let plan = plan(&config, &state, &all, false, probe_in(&present));
544
545 assert_eq!(plan.enable, vec![VendorId::Cursor]);
546 }
547
548 #[test]
549 fn an_opt_in_vendor_is_neither_probed_nor_counted() {
550 let config = Config::default();
551 let state = DetectState::default();
552 let all = [VendorId::Cursor, VendorId::Devin, VendorId::Kiro];
553 for force in [false, true] {
554 let probed_vendors = std::cell::RefCell::new(Vec::new());
555 let plan = plan(&config, &state, &all, force, |vendor| {
556 probed_vendors.borrow_mut().push(vendor);
557 true
558 });
559 assert_eq!(plan.enable, vec![VendorId::Cursor, VendorId::Kiro]);
560 assert_eq!(plan.probed, 2, "force={force}");
561 assert!(!probed_vendors.borrow().contains(&VendorId::Devin));
562 assert!(plan.known.contains(&VendorId::Devin));
563 }
564 assert!(VendorId::all().iter().all(|vendor| vendor.auto_detectable()
565 != matches!(vendor, VendorId::Devin | VendorId::MuseCode)));
566 }
567
568 #[test]
569 fn local_devin_credentials_never_auto_enable_an_opt_in_vendor() {
570 let dir = TempDir::new().unwrap();
571 let config_path = dir.path().join("config.toml");
572 let state_path = dir.path().join("detect.json");
573 let credential_path = dir.path().join("credentials.toml");
574 std::fs::write(&credential_path, "present").unwrap();
575
576 for (contents, expected_enabled) in [
577 ("", false),
578 ("[devin]\nenabled = false\n", false),
579 ("[devin]\nenabled = true\n", true),
580 ] {
581 std::fs::write(&config_path, contents).unwrap();
582 for force in [false, true] {
583 let report = run_once_with(Some(&config_path), &state_path, force, |vendor, _| {
584 vendor == VendorId::Devin && credential_path.is_file()
585 })
586 .unwrap();
587
588 assert!(
589 report.enabled.is_empty(),
590 "{contents:?}, force={force}: {report:?}"
591 );
592 assert!(credential_path.is_file());
593 assert!(report.known.contains(&VendorId::Devin));
594 assert_eq!(
595 Config::load_from(&config_path).unwrap().devin.enabled,
596 expected_enabled
597 );
598 }
599 }
600 }
601
602 #[test]
603 fn force_reconsiders_known_vendors_but_never_enabled_ones() {
604 let config = Config::default();
605 let state = DetectState {
606 known: vec![VendorId::Grok, VendorId::Zai],
607 };
608 let all = [VendorId::Zai, VendorId::Grok];
609 let present = [VendorId::Zai, VendorId::Grok];
610
611 let plan = plan(&config, &state, &all, true, probe_in(&present));
612
613 assert_eq!(plan.enable, vec![VendorId::Grok]);
614 }
615
616 #[test]
617 fn plan_orders_enable_by_the_candidate_list() {
618 let config = Config::default();
619 let all = [VendorId::Kiro, VendorId::Cursor, VendorId::Grok];
620 let present = [VendorId::Grok, VendorId::Cursor, VendorId::Kiro];
621
622 let plan = plan(
623 &config,
624 &DetectState::default(),
625 &all,
626 false,
627 probe_in(&present),
628 );
629
630 assert_eq!(
631 plan.enable,
632 vec![VendorId::Kiro, VendorId::Cursor, VendorId::Grok]
633 );
634 }
635
636 #[test]
637 fn known_becomes_the_union_in_canonical_order_without_duplicates() {
638 let config = Config::default();
639 let state = DetectState {
640 known: vec![VendorId::Grok, VendorId::Cursor],
641 };
642 let all = [VendorId::Cursor, VendorId::Anthropic, VendorId::Cursor];
643
644 let plan = plan(&config, &state, &all, false, |_| false);
645
646 assert_eq!(
647 plan.known,
648 vec![VendorId::Anthropic, VendorId::Grok, VendorId::Cursor]
649 );
650 assert!(plan.enable.is_empty());
651 }
652
653 #[test]
654 fn probe_is_not_consulted_for_skipped_vendors() {
655 let config = Config::default();
656 let state = DetectState {
657 known: vec![VendorId::Grok],
658 };
659 let all = [VendorId::Grok, VendorId::Anthropic];
660
661 let plan = plan(&config, &state, &all, false, |vendor| {
662 panic!("probe called for {}", vendor.slug())
663 });
664
665 assert!(plan.enable.is_empty());
666 }
667
668 #[test]
669 fn a_named_api_key_account_alone_counts_as_a_credential() {
670 let mut config = Config::default();
672 config.deepseek.api_key_env.clear();
673 assert!(!key_present(&config, VendorId::Deepseek));
674
675 config.deepseek.accounts.push(crate::config::ApiKeyAccount {
676 label: "work".into(),
677 api_key_env: None,
678 api_key: Some("work-key".into()),
679 management_api_key_env: None,
680 });
681 assert!(key_present(&config, VendorId::Deepseek));
682 config.kilo.api_key_env.clear();
684 assert!(!key_present(&config, VendorId::Kilo));
685 }
686
687 #[test]
688 fn a_named_anthropic_account_alone_counts_as_a_credential() {
689 let dir = TempDir::new().unwrap();
690 let creds_file = dir.path().join("work.json");
691 std::fs::write(
692 &creds_file,
693 r#"{"claudeAiOauth":{"accessToken":"tok","refreshToken":"ref","expiresAt":2000000000000}}"#,
694 )
695 .unwrap();
696
697 let mut config = Config::default();
698 config.anthropic.credentials_path = Some(dir.path().join("absent.json"));
699 assert!(!anthropic_present(&config));
700
701 config
702 .anthropic
703 .accounts
704 .push(crate::config::AnthropicAccount {
705 label: "work".into(),
706 credentials_path: creds_file,
707 });
708 assert!(anthropic_present(&config));
709 }
710
711 #[test]
712 fn a_named_openai_account_alone_counts_as_a_credential() {
713 let dir = TempDir::new().unwrap();
714 let auth_file = dir.path().join("work-auth.json");
715 std::fs::write(
716 &auth_file,
717 r#"{"tokens":{"access_token":"a","refresh_token":"r","id_token":"i"}}"#,
718 )
719 .unwrap();
720
721 let mut config = Config::default();
722 config.openai.codex_auth_path = Some(dir.path().join("absent.json"));
723 assert!(!has_local_credentials(VendorId::Openai, &config));
724
725 config.openai.accounts.push(crate::config::OpenAiAccount {
726 label: "work".into(),
727 codex_auth_path: auth_file,
728 });
729 assert!(has_local_credentials(VendorId::Openai, &config));
730 }
731
732 #[test]
733 fn copilot_hosts_file_must_be_a_non_empty_regular_file() {
734 let dir = TempDir::new().unwrap();
735 let hosts = dir.path().join("hosts.yml");
736 assert!(!copilot_hosts_present_at(&hosts));
737
738 std::fs::write(&hosts, "").unwrap();
739 assert!(!copilot_hosts_present_at(&hosts));
740
741 std::fs::write(&hosts, "github.com:\n user: octocat\n").unwrap();
742 assert!(copilot_hosts_present_at(&hosts));
743
744 assert!(!copilot_hosts_present_at(dir.path()));
745 }
746
747 #[test]
748 fn devin_local_credential_probe_checks_only_the_configured_file() {
749 let dir = TempDir::new().unwrap();
750 let credential_path = dir.path().join("credentials.toml");
751 let mut config = Config::default();
752 config.devin.credentials_path = Some(credential_path.clone());
753 assert!(!has_local_credentials(VendorId::Devin, &config));
754
755 std::fs::write(&credential_path, "not parsed during detection").unwrap();
756 assert!(has_local_credentials(VendorId::Devin, &config));
757
758 let directory = dir.path().join("directory");
759 std::fs::create_dir(&directory).unwrap();
760 config.devin.credentials_path = Some(directory);
761 assert!(!has_local_credentials(VendorId::Devin, &config));
762 }
763
764 #[test]
768 fn run_once_writes_enables_into_the_config_and_marks_everything_known() {
769 let dir = TempDir::new().unwrap();
770 let config_path = dir.path().join("config.toml");
771 let state_path = dir.path().join("detect.json");
772 std::fs::write(
773 &config_path,
774 "# mine
775[zai]
776enabled = false
777",
778 )
779 .unwrap();
780 let present = [VendorId::Zai, VendorId::Cursor, VendorId::Anthropic];
781 let probe = |vendor: VendorId, _: &Config| present.contains(&vendor);
782
783 let report = run_once_with(Some(&config_path), &state_path, false, probe).unwrap();
784
785 assert_eq!(report.enabled, vec![VendorId::Cursor]);
789 assert_eq!(report.known, VendorId::all());
790 assert_eq!(report.probed, detectable_count());
791 let after = Config::load_from(&config_path).unwrap();
792 assert!(!after.is_enabled(VendorId::Zai), "an opt-out must survive");
793 assert!(after.is_enabled(VendorId::Cursor));
794 let text = std::fs::read_to_string(&config_path).unwrap();
795 assert!(
796 text.starts_with(
797 "# mine
798"
799 ),
800 "{text}"
801 );
802 assert_eq!(DetectState::load_at(&state_path).known, VendorId::all());
803
804 let again = run_once_with(Some(&config_path), &state_path, false, probe).unwrap();
805 assert!(again.enabled.is_empty(), "{again:?}");
806 assert_eq!(again.probed, 0, "everything is known: nothing to check");
807
808 std::fs::write(
810 &config_path,
811 "[cursor]
812enabled = false
813",
814 )
815 .unwrap();
816 let third = run_once_with(Some(&config_path), &state_path, false, probe).unwrap();
817 assert!(third.enabled.is_empty(), "{third:?}");
818 assert!(
819 !Config::load_from(&config_path)
820 .unwrap()
821 .is_enabled(VendorId::Cursor)
822 );
823
824 let forced = run_once_with(Some(&config_path), &state_path, true, probe).unwrap();
829 assert!(forced.enabled.is_empty(), "{forced:?}");
830 assert_eq!(forced.probed, detectable_count());
831 assert!(
832 !Config::load_from(&config_path)
833 .unwrap()
834 .is_enabled(VendorId::Cursor)
835 );
836 }
837
838 #[test]
839 fn plan_counts_candidates_not_enables() {
840 let config = Config::default();
841 let state = DetectState {
842 known: vec![VendorId::Grok],
843 };
844 let all = [VendorId::Anthropic, VendorId::Grok, VendorId::Cursor];
845
846 let unforced = plan(&config, &state, &all, false, |_| false);
847 assert_eq!(unforced.probed, 2, "Grok is known and skipped");
848
849 let forced = plan(&config, &state, &all, true, |_| false);
850 assert_eq!(forced.probed, 3);
851 }
852
853 #[test]
854 fn format_report_lists_display_names_and_where_they_were_written() {
855 let report = DetectReport {
856 enabled: vec![VendorId::Cursor, VendorId::Kiro],
857 known: VendorId::all().to_vec(),
858 probed: 3,
859 };
860
861 let text = format_report(&report, "/home/u/.config/ai-usagebar/config.toml");
862
863 assert_eq!(
864 text,
865 "Enabled: Cursor, Kiro\nWritten to /home/u/.config/ai-usagebar/config.toml"
866 );
867 }
868
869 #[test]
870 fn format_report_says_how_many_were_checked_when_nothing_changed() {
871 let none = DetectReport {
872 enabled: vec![],
873 known: VendorId::all().to_vec(),
874 probed: 3,
875 };
876 assert_eq!(
877 format_report(&none, "unused"),
878 "Nothing new detected (3 vendors checked)"
879 );
880
881 let one = DetectReport {
882 probed: 1,
883 ..none.clone()
884 };
885 assert_eq!(
886 format_report(&one, "unused"),
887 "Nothing new detected (1 vendor checked)"
888 );
889 }
890
891 #[test]
893 fn report_serializes_slugs_and_the_probed_count() {
894 let dir = TempDir::new().unwrap();
895 let config_path = dir.path().join("config.toml");
896 let state_path = dir.path().join("detect.json");
897 std::fs::write(&config_path, "").unwrap();
898 DetectState {
899 known: vec![VendorId::Anthropic, VendorId::Grok],
900 }
901 .save_at(&state_path)
902 .unwrap();
903 let probe = |vendor: VendorId, _: &Config| vendor == VendorId::Cursor;
904
905 let report = run_once_with(Some(&config_path), &state_path, false, probe).unwrap();
906 let json: serde_json::Value = serde_json::to_value(&report).unwrap();
907
908 assert_eq!(json["enabled"], serde_json::json!(["cursor"]));
909 assert_eq!(
910 json["probed"],
911 serde_json::json!(detectable_count() - 2),
912 "the two known vendors were not candidates"
913 );
914 let known = json["known"].as_array().unwrap();
915 assert_eq!(known.len(), VendorId::all().len());
916 assert_eq!(known[0], serde_json::json!("anthropic"));
917 assert_eq!(json.as_object().unwrap().len(), 3, "{json}");
918 }
919
920 #[test]
921 fn a_panicking_probe_counts_as_absent_and_still_saves_state() {
922 let dir = TempDir::new().unwrap();
923 let config_path = dir.path().join("config.toml");
924 let state_path = dir.path().join("detect.json");
925 let probe = |vendor: VendorId, _: &Config| {
926 catch_unwind(AssertUnwindSafe(|| {
927 if vendor == VendorId::Kiro {
928 panic!("boom");
929 }
930 vendor == VendorId::Grok
931 }))
932 .unwrap_or(false)
933 };
934
935 let enabled = run_once_with(Some(&config_path), &state_path, false, probe).unwrap();
936
937 assert_eq!(enabled.enabled, vec![VendorId::Grok]);
938 assert_eq!(DetectState::load_at(&state_path).known, VendorId::all());
939 }
940}