clauth 0.8.0

Manage multiple Claude Code accounts, monitor 5h/7d usage with configurable auto-switch and delegation with an MCP plugin. CLI + TUI.
#![allow(clippy::unwrap_used, clippy::expect_used)]
#![cfg(unix)]

//! Guard coverage for the MCP `switch` tool itself (the `ClauthServer::switch`
//! seam, not the `switch_profile_noninteractive` action it wraps). An unknown or
//! wrong-case profile name must be rejected BEFORE any credential mutation:
//! without the canonical-name guard the raw arg reaches `link_profile_credentials`,
//! which removes the live `~/.claude/.credentials.json` symlink and creates no
//! replacement, leaving the global session credential-less.

use super::*;

use crate::claude::force_link_profile_credentials;
use crate::profile::{
    AppState, ClaudeCredentials, OAuthToken, Profile, claude_dir, save_app_state, save_profile,
};
use crate::testutil::HomeSandbox;

/// Seed one cleanly-linked active profile on disk — profile creds, a symlinked
/// live `~/.claude/.credentials.json`, and persisted app state — so the tool's
/// own `load_config` sees a real active session.
fn seed_active_linked() {
    let mut p = Profile::new("active".to_string(), None, None);
    p.credentials = Some(ClaudeCredentials {
        claude_ai_oauth: Some(OAuthToken {
            access_token: "stored-a".to_string(),
            refresh_token: Some("stored-r".to_string()),
            expires_at: None,
            scopes: None,
            subscription_type: None,
        }),
    });
    save_profile(&p).expect("save profile");
    force_link_profile_credentials("active").expect("link active");

    let state = AppState {
        active_profile: Some("active".into()),
        profiles: vec!["active".into()],
        ..Default::default()
    };
    save_app_state(&state).expect("save state");
}

/// Drive the async `switch` tool on a current-thread runtime.
fn call_switch(name: &str) -> CallToolResult {
    let server = ClauthServer::new();
    let rt = tokio::runtime::Builder::new_current_thread()
        .build()
        .expect("runtime");
    rt.block_on(async {
        server
            .switch(Parameters(SwitchArgs {
                name: name.to_string(),
            }))
            .await
    })
    .expect("switch returns a tool result, never a transport error")
}

#[test]
fn unknown_target_is_rejected_without_stripping_live_creds() {
    let _home = HomeSandbox::new();
    seed_active_linked();

    let live = claude_dir().expect("claude dir").join(".credentials.json");
    assert!(
        live.symlink_metadata().is_ok(),
        "precondition: live credentials are linked",
    );

    let result = call_switch("ghost");
    assert_eq!(
        result.is_error,
        Some(true),
        "an unknown profile name must be a tool error",
    );
    assert!(
        live.symlink_metadata().is_ok(),
        "the live credentials symlink must survive a failed switch to an unknown name",
    );
}