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