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