Skip to main content

ai_usagebar/
detect.rs

1//! Local credential detection — the seed for auto-enabling vendors.
2//!
3//! A fresh install shows the four default vendors and nothing else, even when
4//! the machine already carries a Cursor login, a Kiro database, a `gh` OAuth
5//! session, or a `KILO_API_KEY`. This module answers "which vendors could
6//! fetch right now with what is already on disk?" cheaply enough to run at
7//! every frontend start, and turns the answer into a minimal edit of
8//! `config.toml`: `enabled = true` for the vendors that have credentials and
9//! are not enabled yet. It never writes `false` and never removes anything —
10//! the config stays the user's own.
11//!
12//! Two pieces, mirroring OpenUsage's `hasLocalCredentials` +
13//! FirstRunSeeder/NewProviderSeeder:
14//!
15//! - [`has_local_credentials`] is the per-vendor probe. Files, sqlite, saved
16//!   keys, env vars, local port discovery — **never the network**, never a
17//!   token refresh, never a cache directory or lock file. It reuses the same
18//!   resolvers `build_outcome` reads through, so "detected" means "the fetch
19//!   would at least find its credential".
20//! - [`DetectState`] remembers which vendors have already been considered, so
21//!   a vendor the user deliberately disabled after it was auto-enabled stays
22//!   disabled: it is only ever auto-enabled the *first* time it is seen. New
23//!   vendors added by an upgrade are not in `known` and get their one chance.
24//!   [`plan`] is the pure decision; [`run_once`] is the whole cycle.
25
26use 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
35/// Cheap, local-only probe: files, sqlite, saved keys, env vars. Never the
36/// network. `true` means the vendor's fetch would find *a* credential — not
37/// that the credential is still valid, which only the wire can tell.
38///
39/// Exhaustive over [`VendorId`] on purpose: a new vendor fails to compile
40/// until it says how it is detected.
41pub 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        // File-exists only: decrypting would mean a `secret-tool` / Keychain
70        // subprocess, and a probe that runs at every frontend start must not
71        // spawn one.
72        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            // `read_unlocked`, not `read`: the latter takes the store lock,
90            // which creates the lock file and a 0700 config dir as a side
91            // effect. A probe must leave no trace.
92            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 => key_present(config, vendor),
98        VendorId::CommandCode => {
99            crate::commandcode::creds::resolve(config.commandcode.auth_paths.as_deref()).is_ok()
100        }
101        VendorId::Ollama => key_present(config, vendor),
102        VendorId::OrcaRouter => key_present(config, vendor),
103        VendorId::Lyceum => key_present(config, vendor),
104        // File-exists only, like Grok Bot: parsing the JSON here would be
105        // wasted work — the fetch reads the same file and reports honestly.
106        VendorId::ModelStudio => crate::modelstudio::config_path(&config.modelstudio)
107            .map(|path| crate::modelstudio::creds::config_present_at(&path))
108            .unwrap_or(false),
109    }
110}
111
112/// A key vendor: the configured env var (`api_key_env`, defaulting to
113/// `VendorId::api_key_env`) or the inline `api_key`, exactly as the fetch
114/// resolves them. `Config::api_key_env_for` / `inline_api_key` are the shared
115/// per-vendor lookup, so a new key vendor needs no arm of its own here. A
116/// named `[[<vendor>.accounts]]` key counts too: those accounts get tabs of
117/// their own, so a config with only named keys can still fetch.
118fn key_present(config: &Config, vendor: VendorId) -> bool {
119    crate::config::optional_api_key(
120        config.api_key_env_for(vendor),
121        config.inline_api_key(vendor),
122    )
123    .is_some()
124        || config
125            .api_key_accounts(vendor)
126            .unwrap_or(&[])
127            .iter()
128            .any(|account| {
129                crate::config::optional_api_key(
130                    account.api_key_env.as_deref().unwrap_or(""),
131                    account.api_key.as_deref(),
132                )
133                .is_some()
134            })
135}
136
137/// The default Claude account exactly as the fetch resolves it: an explicit
138/// `credentials_path` is a strict file read; the platform default adds the
139/// macOS Keychain fallback inside `creds::resolve` (gated there, not here).
140/// Any named account with a resolvable credential counts as present too.
141fn anthropic_present(config: &Config) -> bool {
142    use crate::anthropic::creds::{CredsTarget, default_path, resolve};
143    let target = match config.anthropic.credentials_path.clone() {
144        Some(path) => CredsTarget::Explicit(path),
145        None => match default_path() {
146            Ok(path) => CredsTarget::Default(path),
147            Err(_) => return false,
148        },
149    };
150    resolve(&target).is_ok()
151        || config.anthropic.all_accounts().iter().any(|account| {
152            resolve(&CredsTarget::Named {
153                path: account.credentials_path.clone(),
154                config_dir: account.config_dir(),
155            })
156            .is_ok()
157        })
158}
159
160/// GitHub Copilot detection is a **new** heuristic, deliberately different
161/// from the fetch: the fetch spawns `gh auth token` and lets the GitHub CLI
162/// decide, but a probe that runs at every frontend start must not fork a
163/// subprocess per vendor. So this asks the two questions `gh auth token`
164/// would answer from: an explicit `GITHUB_COPILOT_TOKEN`, or the `hosts.yml`
165/// that `gh auth login` has written, at the path `gh` itself would read
166/// (`copilot::credentials::default_hosts_path`). A present `hosts.yml` is
167/// treated as a login; if the session inside it has been revoked, the fetch
168/// reports that, as it would for any stale credential.
169fn copilot_present() -> bool {
170    if std::env::var_os("GITHUB_COPILOT_TOKEN").is_some_and(|value| !value.is_empty()) {
171        return true;
172    }
173    crate::copilot::credentials::default_hosts_path()
174        .is_ok_and(|path| copilot_hosts_present_at(&path))
175}
176
177/// A `hosts.yml` counts when it is a regular, non-empty file: `gh auth logout`
178/// of the last host leaves an empty document behind, which is not a login.
179pub(crate) fn copilot_hosts_present_at(path: &Path) -> bool {
180    std::fs::metadata(path).is_ok_and(|meta| meta.is_file() && meta.len() > 0)
181}
182
183/// Antigravity has no API key: it is "present" when a product is running and
184/// reachable, which is a local port question. The env override counts on its
185/// own, as it does for the fetch's candidate list. Cheapest check first.
186fn antigravity_present() -> bool {
187    if std::env::var_os("ANTIGRAVITY_LS_ADDRESS").is_some_and(|value| !value.is_empty()) {
188        return true;
189    }
190    if !crate::antigravity::fetch::discover_ls_ports().is_empty() {
191        return true;
192    }
193    // Antigravity also reports with every product closed, from the Google
194    // session it saved. Detecting only a *running* server would skip a
195    // provider that works — and because a vendor is looked at once, the miss
196    // would stick until `--all`. Our own cached token is the prompt-free
197    // signal that the remote path is live; the keyring is deliberately not
198    // consulted here.
199    crate::cache::Cache::for_vendor(crate::vendor::VendorId::Antigravity.slug()).is_ok_and(
200        |cache| {
201            crate::antigravity::cloud::has_persisted_session(
202                &crate::antigravity::cloud::oauth_cache_path(&cache),
203            )
204        },
205    )
206}
207
208fn cursor_present(config: &Config) -> bool {
209    let db_path = match config.cursor.db_path.clone() {
210        Some(path) => path,
211        None => match crate::cursor::db::default_db_path() {
212            Ok(path) => path,
213            Err(_) => return false,
214        },
215    };
216    let agent_auth_path = match config.cursor.agent_auth_path.clone() {
217        Some(path) => path,
218        None => match crate::cursor::db::default_agent_auth_path() {
219            Ok(path) => path,
220            Err(_) => return false,
221        },
222    };
223    crate::cursor::db::resolve_access_token(&db_path, &agent_auth_path).is_ok()
224}
225
226/// Which vendors detection has already had its one look at. Persisted as
227/// JSON next to the vendor caches; a vendor in `known` is never auto-enabled
228/// again, so a user's later `enabled = false` sticks.
229#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
230pub struct DetectState {
231    #[serde(default)]
232    pub known: Vec<VendorId>,
233}
234
235impl DetectState {
236    /// Missing or unreadable state means "nothing has been considered yet",
237    /// which only ever widens the config — the safe direction for a corrupt
238    /// sidecar. A slug the current build doesn't know parses as corrupt too.
239    pub fn load_at(path: &Path) -> DetectState {
240        std::fs::read(path)
241            .ok()
242            .and_then(|bytes| serde_json::from_slice(&bytes).ok())
243            .unwrap_or_default()
244    }
245
246    /// Atomic write (tempfile + rename), creating the parent directory.
247    pub fn save_at(&self, path: &Path) -> Result<()> {
248        let bytes = serde_json::to_vec_pretty(self)?;
249        crate::cache::atomic_write(path, &bytes)
250    }
251}
252
253/// `<cache dir>/ai-usagebar/detect.json` — beside the per-vendor caches,
254/// because it is derived state that can be deleted to re-run detection.
255pub fn default_state_path() -> Result<PathBuf> {
256    Ok(crate::cache::xdg_cache_dir()?
257        .join("ai-usagebar")
258        .join("detect.json"))
259}
260
261/// What one detection pass decided.
262#[derive(Debug, Default, Clone, PartialEq, Eq)]
263pub struct DetectPlan {
264    /// Vendors to flip to `enabled = true`, in `all` order.
265    pub enable: Vec<VendorId>,
266    /// The next `DetectState::known`: the old set plus everything in `all`.
267    pub known: Vec<VendorId>,
268    /// How many candidates this pass considered: every vendor in `all` when
269    /// forced, otherwise only the ones not yet in `state.known`.
270    pub probed: usize,
271}
272
273/// What one full [`run_once_with`] cycle did — the CLI's report and the
274/// serialized shape of `detect --json` (vendors as slugs, no secrets).
275#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize)]
276pub struct DetectReport {
277    /// Vendors flipped to `enabled = true` in the config this run.
278    pub enabled: Vec<VendorId>,
279    /// The persisted `DetectState::known` after this run.
280    pub known: Vec<VendorId>,
281    /// How many vendors were candidates this run (see [`DetectPlan::probed`]).
282    pub probed: usize,
283}
284
285/// The pure decision. Candidates are `all` minus `state.known`, or every
286/// vendor in `all` when `force`. A candidate is enabled when `probe` says it
287/// has credentials and the config doesn't already enable it. `known` becomes
288/// the union of the old set and `all`, in [`VendorId::all`] order, deduped —
289/// so a vendor is considered once per install, and once more per `force`.
290pub fn plan(
291    config: &Config,
292    state: &DetectState,
293    all: &[VendorId],
294    force: bool,
295    probe: impl Fn(VendorId) -> bool,
296) -> DetectPlan {
297    let candidates: Vec<VendorId> = all
298        .iter()
299        .copied()
300        .filter(|vendor| force || !state.known.contains(vendor))
301        .collect();
302    let probed = candidates.len();
303    let enable = candidates
304        .into_iter()
305        .filter(|vendor| !config.is_enabled(*vendor))
306        .filter(|vendor| probe(*vendor))
307        .collect();
308    let known = VendorId::all()
309        .iter()
310        .copied()
311        .filter(|vendor| state.known.contains(vendor) || all.contains(vendor))
312        .collect();
313    DetectPlan {
314        enable,
315        known,
316        probed,
317    }
318}
319
320/// One full detection cycle: load the config (`config_path`, or the resolved
321/// default), load the state at `state_path`, plan with
322/// [`has_local_credentials`], write the enables into the config, save the
323/// state, and return what was enabled.
324///
325/// Best-effort by design: every probe runs under `catch_unwind`, so a vendor
326/// resolver that panics on an unexpected local file counts as "not present"
327/// rather than taking the host process down.
328pub fn run_once(
329    config_path: Option<&Path>,
330    state_path: &Path,
331    force: bool,
332) -> Result<Vec<VendorId>> {
333    run_once_report(config_path, state_path, force).map(|report| report.enabled)
334}
335
336/// [`run_once`] keeping the whole [`DetectReport`] — what the `detect`
337/// subcommand prints. Same real probe, same `catch_unwind` guard.
338pub fn run_once_report(
339    config_path: Option<&Path>,
340    state_path: &Path,
341    force: bool,
342) -> Result<DetectReport> {
343    run_once_with(config_path, state_path, force, |vendor, config| {
344        catch_unwind(AssertUnwindSafe(|| has_local_credentials(vendor, config))).unwrap_or(false)
345    })
346}
347
348/// `ai-usagebar detect [--all] [--json]`: one-shot local credential detection
349/// as a command, so any frontend (or the user) can run it at startup.
350/// Uses the real config and state paths — tests go through
351/// [`run_once_with`] and [`format_report`] instead.
352pub fn run_cli(all: bool, json: bool) -> i32 {
353    let report =
354        default_state_path().and_then(|state_path| run_once_report(None, &state_path, all));
355    match report {
356        Ok(report) if json => match serde_json::to_string(&report) {
357            Ok(text) => {
358                println!("{text}");
359                0
360            }
361            Err(error) => {
362                eprintln!("ai-usagebar detect: {error}");
363                1
364            }
365        },
366        Ok(report) => {
367            println!(
368                "{}",
369                format_report(&report, &crate::config::config_path_hint())
370            );
371            0
372        }
373        Err(error) => {
374            eprintln!("ai-usagebar detect: {}", error.user_message());
375            1
376        }
377    }
378}
379
380/// Human-readable `detect` output. `config_hint` is where the enables were
381/// written, shown only when something was enabled.
382pub fn format_report(report: &DetectReport, config_hint: &str) -> String {
383    if report.enabled.is_empty() {
384        let noun = if report.probed == 1 {
385            "vendor"
386        } else {
387            "vendors"
388        };
389        return format!("Nothing new detected ({} {noun} checked)", report.probed);
390    }
391    let names: Vec<&str> = report
392        .enabled
393        .iter()
394        .map(|vendor| vendor.display_name())
395        .collect();
396    format!("Enabled: {}\nWritten to {config_hint}", names.join(", "))
397}
398
399/// [`run_once_report`] with the probe injected — the test seam, so the
400/// cycle's config write and state bookkeeping can be exercised without a
401/// probe that reads this machine's real credential files.
402pub fn run_once_with(
403    config_path: Option<&Path>,
404    state_path: &Path,
405    force: bool,
406    probe: impl Fn(VendorId, &Config) -> bool,
407) -> Result<DetectReport> {
408    let resolved = match config_path {
409        Some(path) => Some(path.to_path_buf()),
410        None => crate::config::resolved_path(),
411    };
412    let config = match &resolved {
413        Some(path) => Config::load_from(path)?,
414        None => Config::default(),
415    };
416    let state = DetectState::load_at(state_path);
417    let plan = plan(&config, &state, VendorId::all(), force, |vendor| {
418        probe(vendor, &config)
419    });
420    // What actually got written, which is not always what was planned: a
421    // vendor the user explicitly set to `enabled = false` is left alone by
422    // `enable_vendors_in`, so it must not be reported as enabled either.
423    let enabled = if plan.enable.is_empty() {
424        Vec::new()
425    } else {
426        let path = resolved.ok_or_else(|| {
427            AppError::Other("could not resolve the config.toml path to enable vendors in".into())
428        })?;
429        crate::config::enable_vendors_in(&path, &plan.enable)?
430    };
431    DetectState {
432        known: plan.known.clone(),
433    }
434    .save_at(state_path)?;
435    Ok(DetectReport {
436        enabled,
437        known: plan.known,
438        probed: plan.probed,
439    })
440}
441
442#[cfg(test)]
443mod tests {
444    use super::*;
445    use tempfile::TempDir;
446
447    #[test]
448    fn lyceum_credential_detection_uses_inline_key_without_home_or_network() {
449        let mut config = Config::default();
450        config.lyceum.api_key_env.clear(); // prevents reading any ambient variable
451        config.lyceum.api_key = Some("synthetic-key".into());
452        assert!(has_local_credentials(VendorId::Lyceum, &config));
453        config.lyceum.api_key = None;
454        assert!(!has_local_credentials(VendorId::Lyceum, &config));
455    }
456
457    fn probe_in(present: &[VendorId]) -> impl Fn(VendorId) -> bool + '_ {
458        move |vendor| present.contains(&vendor)
459    }
460
461    #[test]
462    fn state_round_trips_through_json_by_slug() {
463        let dir = TempDir::new().unwrap();
464        let path = dir.path().join("nested").join("detect.json");
465        let state = DetectState {
466            known: vec![
467                VendorId::Cursor,
468                VendorId::OpenCodeGo,
469                VendorId::NousResearch,
470            ],
471        };
472
473        state.save_at(&path).unwrap();
474
475        let text = std::fs::read_to_string(&path).unwrap();
476        assert!(text.contains("\"opencode-go\""), "{text}");
477        assert!(text.contains("\"nous\""), "{text}");
478        assert_eq!(DetectState::load_at(&path), state);
479    }
480
481    #[test]
482    fn missing_or_corrupt_state_is_the_default() {
483        let dir = TempDir::new().unwrap();
484        assert_eq!(
485            DetectState::load_at(&dir.path().join("absent.json")),
486            DetectState::default()
487        );
488
489        let corrupt = dir.path().join("corrupt.json");
490        std::fs::write(&corrupt, "{\"known\": [\"not-a-vendor\"").unwrap();
491        assert_eq!(DetectState::load_at(&corrupt), DetectState::default());
492
493        let unknown_slug = dir.path().join("unknown.json");
494        std::fs::write(&unknown_slug, "{\"known\": [\"not-a-vendor\"]}").unwrap();
495        assert_eq!(DetectState::load_at(&unknown_slug), DetectState::default());
496    }
497
498    #[test]
499    fn plan_enables_only_unknown_probed_vendors_that_are_off() {
500        let config = Config::default(); // anthropic/openai/zai/openrouter on
501        let state = DetectState {
502            known: vec![VendorId::Grok],
503        };
504        let all = [
505            VendorId::Anthropic, // enabled already → never listed
506            VendorId::Grok,      // known → skipped
507            VendorId::Cursor,    // present, off, new → enabled
508            VendorId::Kiro,      // absent → not enabled
509        ];
510        let present = [VendorId::Anthropic, VendorId::Grok, VendorId::Cursor];
511
512        let plan = plan(&config, &state, &all, false, probe_in(&present));
513
514        assert_eq!(plan.enable, vec![VendorId::Cursor]);
515    }
516
517    #[test]
518    fn force_reconsiders_known_vendors_but_never_enabled_ones() {
519        let config = Config::default();
520        let state = DetectState {
521            known: vec![VendorId::Grok, VendorId::Zai],
522        };
523        let all = [VendorId::Zai, VendorId::Grok];
524        let present = [VendorId::Zai, VendorId::Grok];
525
526        let plan = plan(&config, &state, &all, true, probe_in(&present));
527
528        assert_eq!(plan.enable, vec![VendorId::Grok]);
529    }
530
531    #[test]
532    fn plan_orders_enable_by_the_candidate_list() {
533        let config = Config::default();
534        let all = [VendorId::Kiro, VendorId::Cursor, VendorId::Grok];
535        let present = [VendorId::Grok, VendorId::Cursor, VendorId::Kiro];
536
537        let plan = plan(
538            &config,
539            &DetectState::default(),
540            &all,
541            false,
542            probe_in(&present),
543        );
544
545        assert_eq!(
546            plan.enable,
547            vec![VendorId::Kiro, VendorId::Cursor, VendorId::Grok]
548        );
549    }
550
551    #[test]
552    fn known_becomes_the_union_in_canonical_order_without_duplicates() {
553        let config = Config::default();
554        let state = DetectState {
555            known: vec![VendorId::Grok, VendorId::Cursor],
556        };
557        let all = [VendorId::Cursor, VendorId::Anthropic, VendorId::Cursor];
558
559        let plan = plan(&config, &state, &all, false, |_| false);
560
561        assert_eq!(
562            plan.known,
563            vec![VendorId::Anthropic, VendorId::Grok, VendorId::Cursor]
564        );
565        assert!(plan.enable.is_empty());
566    }
567
568    #[test]
569    fn probe_is_not_consulted_for_skipped_vendors() {
570        let config = Config::default();
571        let state = DetectState {
572            known: vec![VendorId::Grok],
573        };
574        let all = [VendorId::Grok, VendorId::Anthropic];
575
576        let plan = plan(&config, &state, &all, false, |vendor| {
577            panic!("probe called for {}", vendor.slug())
578        });
579
580        assert!(plan.enable.is_empty());
581    }
582
583    #[test]
584    fn a_named_api_key_account_alone_counts_as_a_credential() {
585        // Empty `api_key_env`s keep the probe off the real environment.
586        let mut config = Config::default();
587        config.deepseek.api_key_env.clear();
588        assert!(!key_present(&config, VendorId::Deepseek));
589
590        config.deepseek.accounts.push(crate::config::ApiKeyAccount {
591            label: "work".into(),
592            api_key_env: None,
593            api_key: Some("work-key".into()),
594            management_api_key_env: None,
595        });
596        assert!(key_present(&config, VendorId::Deepseek));
597        // Another vendor's array is not this vendor's credential.
598        config.kilo.api_key_env.clear();
599        assert!(!key_present(&config, VendorId::Kilo));
600    }
601
602    #[test]
603    fn a_named_anthropic_account_alone_counts_as_a_credential() {
604        let dir = TempDir::new().unwrap();
605        let creds_file = dir.path().join("work.json");
606        std::fs::write(
607            &creds_file,
608            r#"{"claudeAiOauth":{"accessToken":"tok","refreshToken":"ref","expiresAt":2000000000000}}"#,
609        )
610        .unwrap();
611
612        let mut config = Config::default();
613        config.anthropic.credentials_path = Some(dir.path().join("absent.json"));
614        assert!(!anthropic_present(&config));
615
616        config
617            .anthropic
618            .accounts
619            .push(crate::config::AnthropicAccount {
620                label: "work".into(),
621                credentials_path: creds_file,
622            });
623        assert!(anthropic_present(&config));
624    }
625
626    #[test]
627    fn a_named_openai_account_alone_counts_as_a_credential() {
628        let dir = TempDir::new().unwrap();
629        let auth_file = dir.path().join("work-auth.json");
630        std::fs::write(
631            &auth_file,
632            r#"{"tokens":{"access_token":"a","refresh_token":"r","id_token":"i"}}"#,
633        )
634        .unwrap();
635
636        let mut config = Config::default();
637        config.openai.codex_auth_path = Some(dir.path().join("absent.json"));
638        assert!(!has_local_credentials(VendorId::Openai, &config));
639
640        config.openai.accounts.push(crate::config::OpenAiAccount {
641            label: "work".into(),
642            codex_auth_path: auth_file,
643        });
644        assert!(has_local_credentials(VendorId::Openai, &config));
645    }
646
647    #[test]
648    fn copilot_hosts_file_must_be_a_non_empty_regular_file() {
649        let dir = TempDir::new().unwrap();
650        let hosts = dir.path().join("hosts.yml");
651        assert!(!copilot_hosts_present_at(&hosts));
652
653        std::fs::write(&hosts, "").unwrap();
654        assert!(!copilot_hosts_present_at(&hosts));
655
656        std::fs::write(&hosts, "github.com:\n    user: octocat\n").unwrap();
657        assert!(copilot_hosts_present_at(&hosts));
658
659        assert!(!copilot_hosts_present_at(dir.path()));
660    }
661
662    /// The whole cycle against a temp config and state, with the probe faked:
663    /// enables land in the config with the user's text intact, the state
664    /// records every vendor, and a second run has nothing left to do.
665    #[test]
666    fn run_once_writes_enables_into_the_config_and_marks_everything_known() {
667        let dir = TempDir::new().unwrap();
668        let config_path = dir.path().join("config.toml");
669        let state_path = dir.path().join("detect.json");
670        std::fs::write(
671            &config_path,
672            "# mine
673[zai]
674enabled = false
675",
676        )
677        .unwrap();
678        let present = [VendorId::Zai, VendorId::Cursor, VendorId::Anthropic];
679        let probe = |vendor: VendorId, _: &Config| present.contains(&vendor);
680
681        let report = run_once_with(Some(&config_path), &state_path, false, probe).unwrap();
682
683        // Z.AI is detectable and was probed, but the config says `enabled =
684        // false`. That is the user's answer and it outranks detection, so it is
685        // neither written nor reported as enabled.
686        assert_eq!(report.enabled, vec![VendorId::Cursor]);
687        assert_eq!(report.known, VendorId::all());
688        assert_eq!(report.probed, VendorId::all().len());
689        let after = Config::load_from(&config_path).unwrap();
690        assert!(!after.is_enabled(VendorId::Zai), "an opt-out must survive");
691        assert!(after.is_enabled(VendorId::Cursor));
692        let text = std::fs::read_to_string(&config_path).unwrap();
693        assert!(
694            text.starts_with(
695                "# mine
696"
697            ),
698            "{text}"
699        );
700        assert_eq!(DetectState::load_at(&state_path).known, VendorId::all());
701
702        let again = run_once_with(Some(&config_path), &state_path, false, probe).unwrap();
703        assert!(again.enabled.is_empty(), "{again:?}");
704        assert_eq!(again.probed, 0, "everything is known: nothing to check");
705
706        // A user who turns Cursor back off is not overridden: it is known.
707        std::fs::write(
708            &config_path,
709            "[cursor]
710enabled = false
711",
712        )
713        .unwrap();
714        let third = run_once_with(Some(&config_path), &state_path, false, probe).unwrap();
715        assert!(third.enabled.is_empty(), "{third:?}");
716        assert!(
717            !Config::load_from(&config_path)
718                .unwrap()
719                .is_enabled(VendorId::Cursor)
720        );
721
722        // `force` re-probes every vendor, but it still cannot overrule an
723        // explicit `enabled = false`. That makes `detect --all` safe to run at
724        // any time: it can add providers, never silently undo a decision. A
725        // user who wants Cursor back turns it on in Settings or in the file.
726        let forced = run_once_with(Some(&config_path), &state_path, true, probe).unwrap();
727        assert!(forced.enabled.is_empty(), "{forced:?}");
728        assert_eq!(forced.probed, VendorId::all().len());
729        assert!(
730            !Config::load_from(&config_path)
731                .unwrap()
732                .is_enabled(VendorId::Cursor)
733        );
734    }
735
736    #[test]
737    fn plan_counts_candidates_not_enables() {
738        let config = Config::default();
739        let state = DetectState {
740            known: vec![VendorId::Grok],
741        };
742        let all = [VendorId::Anthropic, VendorId::Grok, VendorId::Cursor];
743
744        let unforced = plan(&config, &state, &all, false, |_| false);
745        assert_eq!(unforced.probed, 2, "Grok is known and skipped");
746
747        let forced = plan(&config, &state, &all, true, |_| false);
748        assert_eq!(forced.probed, 3);
749    }
750
751    #[test]
752    fn format_report_lists_display_names_and_where_they_were_written() {
753        let report = DetectReport {
754            enabled: vec![VendorId::Cursor, VendorId::Kiro],
755            known: VendorId::all().to_vec(),
756            probed: 3,
757        };
758
759        let text = format_report(&report, "/home/u/.config/ai-usagebar/config.toml");
760
761        assert_eq!(
762            text,
763            "Enabled: Cursor, Kiro\nWritten to /home/u/.config/ai-usagebar/config.toml"
764        );
765    }
766
767    #[test]
768    fn format_report_says_how_many_were_checked_when_nothing_changed() {
769        let none = DetectReport {
770            enabled: vec![],
771            known: VendorId::all().to_vec(),
772            probed: 3,
773        };
774        assert_eq!(
775            format_report(&none, "unused"),
776            "Nothing new detected (3 vendors checked)"
777        );
778
779        let one = DetectReport {
780            probed: 1,
781            ..none.clone()
782        };
783        assert_eq!(
784            format_report(&one, "unused"),
785            "Nothing new detected (1 vendor checked)"
786        );
787    }
788
789    /// The `--json` contract: slugs, three fields, no paths and no secrets.
790    #[test]
791    fn report_serializes_slugs_and_the_probed_count() {
792        let dir = TempDir::new().unwrap();
793        let config_path = dir.path().join("config.toml");
794        let state_path = dir.path().join("detect.json");
795        std::fs::write(&config_path, "").unwrap();
796        DetectState {
797            known: vec![VendorId::Anthropic, VendorId::Grok],
798        }
799        .save_at(&state_path)
800        .unwrap();
801        let probe = |vendor: VendorId, _: &Config| vendor == VendorId::Cursor;
802
803        let report = run_once_with(Some(&config_path), &state_path, false, probe).unwrap();
804        let json: serde_json::Value = serde_json::to_value(&report).unwrap();
805
806        assert_eq!(json["enabled"], serde_json::json!(["cursor"]));
807        assert_eq!(
808            json["probed"],
809            serde_json::json!(VendorId::all().len() - 2),
810            "the two known vendors were not candidates"
811        );
812        let known = json["known"].as_array().unwrap();
813        assert_eq!(known.len(), VendorId::all().len());
814        assert_eq!(known[0], serde_json::json!("anthropic"));
815        assert_eq!(json.as_object().unwrap().len(), 3, "{json}");
816    }
817
818    #[test]
819    fn a_panicking_probe_counts_as_absent_and_still_saves_state() {
820        let dir = TempDir::new().unwrap();
821        let config_path = dir.path().join("config.toml");
822        let state_path = dir.path().join("detect.json");
823        let probe = |vendor: VendorId, _: &Config| {
824            catch_unwind(AssertUnwindSafe(|| {
825                if vendor == VendorId::Kiro {
826                    panic!("boom");
827                }
828                vendor == VendorId::Grok
829            }))
830            .unwrap_or(false)
831        };
832
833        let enabled = run_once_with(Some(&config_path), &state_path, false, probe).unwrap();
834
835        assert_eq!(enabled.enabled, vec![VendorId::Grok]);
836        assert_eq!(DetectState::load_at(&state_path).known, VendorId::all());
837    }
838}