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