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