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