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::Devin => crate::devin::credentials_path(&config.devin)
122 .map(|path| path.is_file())
123 .unwrap_or(false),
124 }
125}
126
127fn key_present(config: &Config, vendor: VendorId) -> bool {
134 crate::config::optional_api_key(
135 config.api_key_env_for(vendor),
136 config.inline_api_key(vendor),
137 )
138 .is_some()
139 || config
140 .api_key_accounts(vendor)
141 .unwrap_or(&[])
142 .iter()
143 .any(|account| {
144 crate::config::optional_api_key(
145 account.api_key_env.as_deref().unwrap_or(""),
146 account.api_key.as_deref(),
147 )
148 .is_some()
149 })
150}
151
152fn anthropic_present(config: &Config) -> bool {
157 use crate::anthropic::creds::{CredsTarget, default_path, resolve};
158 let target = match config.anthropic.credentials_path.clone() {
159 Some(path) => CredsTarget::Explicit(path),
160 None => match default_path() {
161 Ok(path) => CredsTarget::Default(path),
162 Err(_) => return false,
163 },
164 };
165 resolve(&target).is_ok()
166 || config.anthropic.all_accounts().iter().any(|account| {
167 resolve(&CredsTarget::Named {
168 path: account.credentials_path.clone(),
169 config_dir: account.config_dir(),
170 })
171 .is_ok()
172 })
173}
174
175fn copilot_present() -> bool {
185 if std::env::var_os("GITHUB_COPILOT_TOKEN").is_some_and(|value| !value.is_empty()) {
186 return true;
187 }
188 crate::copilot::credentials::default_hosts_path()
189 .is_ok_and(|path| copilot_hosts_present_at(&path))
190}
191
192pub(crate) fn copilot_hosts_present_at(path: &Path) -> bool {
195 std::fs::metadata(path).is_ok_and(|meta| meta.is_file() && meta.len() > 0)
196}
197
198fn antigravity_present() -> bool {
202 if std::env::var_os("ANTIGRAVITY_LS_ADDRESS").is_some_and(|value| !value.is_empty()) {
203 return true;
204 }
205 if !crate::antigravity::fetch::discover_ls_ports().is_empty() {
206 return true;
207 }
208 crate::cache::Cache::for_vendor(crate::vendor::VendorId::Antigravity.slug()).is_ok_and(
215 |cache| {
216 crate::antigravity::cloud::has_persisted_session(
217 &crate::antigravity::cloud::oauth_cache_path(&cache),
218 )
219 },
220 )
221}
222
223fn cursor_present(config: &Config) -> bool {
224 let db_path = match config.cursor.db_path.clone() {
225 Some(path) => path,
226 None => match crate::cursor::db::default_db_path() {
227 Ok(path) => path,
228 Err(_) => return false,
229 },
230 };
231 let agent_auth_path = match config.cursor.agent_auth_path.clone() {
232 Some(path) => path,
233 None => match crate::cursor::db::default_agent_auth_path() {
234 Ok(path) => path,
235 Err(_) => return false,
236 },
237 };
238 crate::cursor::db::resolve_access_token(&db_path, &agent_auth_path).is_ok()
239}
240
241#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
245pub struct DetectState {
246 #[serde(default)]
247 pub known: Vec<VendorId>,
248}
249
250impl DetectState {
251 pub fn load_at(path: &Path) -> DetectState {
255 std::fs::read(path)
256 .ok()
257 .and_then(|bytes| serde_json::from_slice(&bytes).ok())
258 .unwrap_or_default()
259 }
260
261 pub fn save_at(&self, path: &Path) -> Result<()> {
263 let bytes = serde_json::to_vec_pretty(self)?;
264 crate::cache::atomic_write(path, &bytes)
265 }
266}
267
268pub fn default_state_path() -> Result<PathBuf> {
271 Ok(crate::cache::xdg_cache_dir()?
272 .join("ai-usagebar")
273 .join("detect.json"))
274}
275
276#[derive(Debug, Default, Clone, PartialEq, Eq)]
278pub struct DetectPlan {
279 pub enable: Vec<VendorId>,
281 pub known: Vec<VendorId>,
283 pub probed: usize,
286}
287
288#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize)]
291pub struct DetectReport {
292 pub enabled: Vec<VendorId>,
294 pub known: Vec<VendorId>,
296 pub probed: usize,
298}
299
300pub fn plan(
309 config: &Config,
310 state: &DetectState,
311 all: &[VendorId],
312 force: bool,
313 probe: impl Fn(VendorId) -> bool,
314) -> DetectPlan {
315 let candidates: Vec<VendorId> = all
316 .iter()
317 .copied()
318 .filter(|vendor| vendor.auto_detectable())
319 .filter(|vendor| force || !state.known.contains(vendor))
320 .collect();
321 let probed = candidates.len();
322 let enable = candidates
323 .into_iter()
324 .filter(|vendor| !config.is_enabled(*vendor))
325 .filter(|vendor| probe(*vendor))
326 .collect();
327 let known = VendorId::all()
328 .iter()
329 .copied()
330 .filter(|vendor| state.known.contains(vendor) || all.contains(vendor))
331 .collect();
332 DetectPlan {
333 enable,
334 known,
335 probed,
336 }
337}
338
339pub fn run_once(
348 config_path: Option<&Path>,
349 state_path: &Path,
350 force: bool,
351) -> Result<Vec<VendorId>> {
352 run_once_report(config_path, state_path, force).map(|report| report.enabled)
353}
354
355pub fn run_once_report(
358 config_path: Option<&Path>,
359 state_path: &Path,
360 force: bool,
361) -> Result<DetectReport> {
362 run_once_with(config_path, state_path, force, |vendor, config| {
363 catch_unwind(AssertUnwindSafe(|| has_local_credentials(vendor, config))).unwrap_or(false)
364 })
365}
366
367pub fn run_cli(all: bool, json: bool) -> i32 {
372 let report =
373 default_state_path().and_then(|state_path| run_once_report(None, &state_path, all));
374 match report {
375 Ok(report) if json => match serde_json::to_string(&report) {
376 Ok(text) => {
377 println!("{text}");
378 0
379 }
380 Err(error) => {
381 eprintln!("ai-usagebar detect: {error}");
382 1
383 }
384 },
385 Ok(report) => {
386 println!(
387 "{}",
388 format_report(&report, &crate::config::config_path_hint())
389 );
390 0
391 }
392 Err(error) => {
393 eprintln!("ai-usagebar detect: {}", error.user_message());
394 1
395 }
396 }
397}
398
399pub fn format_report(report: &DetectReport, config_hint: &str) -> String {
402 if report.enabled.is_empty() {
403 let noun = if report.probed == 1 {
404 "vendor"
405 } else {
406 "vendors"
407 };
408 return format!("Nothing new detected ({} {noun} checked)", report.probed);
409 }
410 let names: Vec<&str> = report
411 .enabled
412 .iter()
413 .map(|vendor| vendor.display_name())
414 .collect();
415 format!("Enabled: {}\nWritten to {config_hint}", names.join(", "))
416}
417
418pub fn run_once_with(
422 config_path: Option<&Path>,
423 state_path: &Path,
424 force: bool,
425 probe: impl Fn(VendorId, &Config) -> bool,
426) -> Result<DetectReport> {
427 let resolved = match config_path {
428 Some(path) => Some(path.to_path_buf()),
429 None => crate::config::resolved_path(),
430 };
431 let config = match &resolved {
432 Some(path) => Config::load_from(path)?,
433 None => Config::default(),
434 };
435 let state = DetectState::load_at(state_path);
436 let plan = plan(&config, &state, VendorId::all(), force, |vendor| {
437 probe(vendor, &config)
438 });
439 let enabled = if plan.enable.is_empty() {
443 Vec::new()
444 } else {
445 let path = resolved.ok_or_else(|| {
446 AppError::Other("could not resolve the config.toml path to enable vendors in".into())
447 })?;
448 crate::config::enable_vendors_in(&path, &plan.enable)?
449 };
450 DetectState {
451 known: plan.known.clone(),
452 }
453 .save_at(state_path)?;
454 Ok(DetectReport {
455 enabled,
456 known: plan.known,
457 probed: plan.probed,
458 })
459}
460
461#[cfg(test)]
462mod tests {
463 use super::*;
464 use tempfile::TempDir;
465
466 #[test]
467 fn lyceum_credential_detection_uses_inline_key_without_home_or_network() {
468 let mut config = Config::default();
469 config.lyceum.api_key_env.clear(); config.lyceum.api_key = Some("synthetic-key".into());
471 assert!(has_local_credentials(VendorId::Lyceum, &config));
472 config.lyceum.api_key = None;
473 assert!(!has_local_credentials(VendorId::Lyceum, &config));
474 }
475
476 fn detectable_count() -> usize {
478 VendorId::all()
479 .iter()
480 .filter(|vendor| vendor.auto_detectable())
481 .count()
482 }
483
484 fn probe_in(present: &[VendorId]) -> impl Fn(VendorId) -> bool + '_ {
485 move |vendor| present.contains(&vendor)
486 }
487
488 #[test]
489 fn state_round_trips_through_json_by_slug() {
490 let dir = TempDir::new().unwrap();
491 let path = dir.path().join("nested").join("detect.json");
492 let state = DetectState {
493 known: vec![
494 VendorId::Cursor,
495 VendorId::OpenCodeGo,
496 VendorId::NousResearch,
497 ],
498 };
499
500 state.save_at(&path).unwrap();
501
502 let text = std::fs::read_to_string(&path).unwrap();
503 assert!(text.contains("\"opencode-go\""), "{text}");
504 assert!(text.contains("\"nous\""), "{text}");
505 assert_eq!(DetectState::load_at(&path), state);
506 }
507
508 #[test]
509 fn missing_or_corrupt_state_is_the_default() {
510 let dir = TempDir::new().unwrap();
511 assert_eq!(
512 DetectState::load_at(&dir.path().join("absent.json")),
513 DetectState::default()
514 );
515
516 let corrupt = dir.path().join("corrupt.json");
517 std::fs::write(&corrupt, "{\"known\": [\"not-a-vendor\"").unwrap();
518 assert_eq!(DetectState::load_at(&corrupt), DetectState::default());
519
520 let unknown_slug = dir.path().join("unknown.json");
521 std::fs::write(&unknown_slug, "{\"known\": [\"not-a-vendor\"]}").unwrap();
522 assert_eq!(DetectState::load_at(&unknown_slug), DetectState::default());
523 }
524
525 #[test]
526 fn plan_enables_only_unknown_probed_vendors_that_are_off() {
527 let config = Config::default(); let state = DetectState {
529 known: vec![VendorId::Grok],
530 };
531 let all = [
532 VendorId::Anthropic, VendorId::Grok, VendorId::Cursor, VendorId::Kiro, ];
537 let present = [VendorId::Anthropic, VendorId::Grok, VendorId::Cursor];
538
539 let plan = plan(&config, &state, &all, false, probe_in(&present));
540
541 assert_eq!(plan.enable, vec![VendorId::Cursor]);
542 }
543
544 #[test]
545 fn an_opt_in_vendor_is_neither_probed_nor_counted() {
546 let config = Config::default();
547 let state = DetectState::default();
548 let all = [VendorId::Cursor, VendorId::Devin, VendorId::Kiro];
549 for force in [false, true] {
550 let probed_vendors = std::cell::RefCell::new(Vec::new());
551 let plan = plan(&config, &state, &all, force, |vendor| {
552 probed_vendors.borrow_mut().push(vendor);
553 true
554 });
555 assert_eq!(plan.enable, vec![VendorId::Cursor, VendorId::Kiro]);
556 assert_eq!(plan.probed, 2, "force={force}");
557 assert!(!probed_vendors.borrow().contains(&VendorId::Devin));
558 assert!(plan.known.contains(&VendorId::Devin));
559 }
560 assert!(
561 VendorId::all()
562 .iter()
563 .all(|vendor| vendor.auto_detectable() == (*vendor != VendorId::Devin))
564 );
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}