use anyhow::Result;
use serde_json::json;
use pidge_client::mcp::McpTokenStore;
use pidge_core::TokenStorage;
use crate::commands::mcp_session::{
backend_name, candidate_backends, preferred_backend_for, try_each_backend,
};
pub async fn run(url: String, store: Option<TokenStorage>, json_output: bool) -> Result<()> {
let preferred = match store {
Some(s) => s,
None => preferred_backend_for(&url, &McpTokenStore::list()?),
};
let candidates = candidate_backends(preferred);
if crate::guardrail::dry_run_active() {
return run_dry(&url, &candidates, json_output);
}
let removed = logout_from(
&candidates,
|backend| McpTokenStore::load(&url, backend).map(|tokens| tokens.is_some()),
|backend| McpTokenStore::delete(&url, backend),
)?;
if json_output {
println!("{}", json!({ "server": url, "removed": removed }));
} else if removed {
println!("Forgot the stored session for {url} (local only).");
eprintln!(
"To revoke sessions on the server, call its accounts_update tool with sign_out_everywhere."
);
} else {
println!("No stored session found for {url}; nothing to remove.");
}
Ok(())
}
fn run_dry(url: &str, candidates: &[TokenStorage], json_output: bool) -> Result<()> {
let present = try_each_backend(candidates, |backend| {
McpTokenStore::load(url, backend).map(|tokens| tokens.is_some())
})?;
let found: Vec<TokenStorage> = present
.into_iter()
.filter_map(|(backend, found)| found.then_some(backend))
.collect();
if json_output {
println!(
"{}",
json!({
"dry_run": true,
"server": url,
"would_remove": !found.is_empty(),
"backends": found.iter().map(|b| backend_name(*b)).collect::<Vec<_>>(),
})
);
} else if found.is_empty() {
println!("Dry run: no stored session found for {url}; nothing would be removed.");
} else {
let names = found
.iter()
.map(|b| backend_name(*b))
.collect::<Vec<_>>()
.join(", ");
println!(
"Dry run: would forget the stored session for {url} (local only), removing it from: {names} (and its server-index entry)."
);
}
Ok(())
}
fn logout_from<E: std::fmt::Display>(
candidates: &[TokenStorage],
mut load: impl FnMut(TokenStorage) -> Result<bool, E>,
mut delete: impl FnMut(TokenStorage) -> Result<(), E>,
) -> Result<bool, E> {
let results = try_each_backend(candidates, |backend| {
let found = match load(backend) {
Ok(found) => found,
Err(e) => {
eprintln!(
"warning: could not read the stored session in the {} backend: {e}; removing it anyway",
backend_name(backend)
);
true
}
};
delete(backend)?;
Ok::<bool, E>(found)
})?;
Ok(results.into_iter().any(|(_, found)| found))
}
#[cfg(test)]
mod tests {
use std::cell::RefCell;
use super::*;
#[test]
fn logout_from_deletes_every_backend_even_when_nothing_was_found() {
let candidates = candidate_backends(TokenStorage::Keychain);
let deleted = RefCell::new(Vec::new());
let removed = logout_from(
&candidates,
|_| Ok::<bool, anyhow::Error>(false),
|backend| {
deleted.borrow_mut().push(backend);
Ok::<(), anyhow::Error>(())
},
)
.unwrap();
assert!(!removed);
assert_eq!(
deleted.into_inner(),
vec![TokenStorage::Keychain, TokenStorage::File],
"delete must run on every candidate, not stop after a miss"
);
}
#[test]
fn logout_from_reports_removed_when_any_backend_had_a_session() {
let candidates = candidate_backends(TokenStorage::Keychain);
let removed = logout_from(
&candidates,
|backend| Ok::<bool, anyhow::Error>(backend == TokenStorage::File),
|_| Ok::<(), anyhow::Error>(()),
)
.unwrap();
assert!(removed);
}
#[test]
fn logout_from_still_deletes_the_preferred_backend_when_it_was_the_hit() {
let candidates = candidate_backends(TokenStorage::Keychain);
let deleted = RefCell::new(Vec::new());
let removed = logout_from(
&candidates,
|backend| Ok::<bool, anyhow::Error>(backend == TokenStorage::Keychain),
|backend| {
deleted.borrow_mut().push(backend);
Ok::<(), anyhow::Error>(())
},
)
.unwrap();
assert!(removed);
assert_eq!(
deleted.into_inner(),
vec![TokenStorage::Keychain, TokenStorage::File]
);
}
#[test]
fn logout_from_still_deletes_when_load_fails() {
let candidates = candidate_backends(TokenStorage::Keychain);
let deleted = RefCell::new(Vec::new());
let removed = logout_from(
&candidates,
|backend| match backend {
TokenStorage::Keychain => Err(anyhow::anyhow!("corrupt blob")),
TokenStorage::File => Ok(false),
},
|backend| {
deleted.borrow_mut().push(backend);
Ok::<(), anyhow::Error>(())
},
)
.unwrap();
assert!(
removed,
"an unreadable entry that was deleted counts as removed"
);
assert_eq!(
deleted.into_inner(),
vec![TokenStorage::Keychain, TokenStorage::File]
);
}
#[test]
fn logout_from_propagates_a_delete_error_from_the_preferred_backend() {
let candidates = candidate_backends(TokenStorage::Keychain);
let err = logout_from(
&candidates,
|_| Err::<bool, _>(anyhow::anyhow!("keychain unavailable")),
|_| Err::<(), _>(anyhow::anyhow!("keychain unavailable")),
)
.unwrap_err();
assert!(err.to_string().contains("keychain unavailable"));
}
#[test]
fn logout_from_tolerates_a_fallback_backend_error() {
let candidates = candidate_backends(TokenStorage::Keychain);
let removed = logout_from(
&candidates,
|backend| match backend {
TokenStorage::Keychain => Ok::<bool, anyhow::Error>(true),
TokenStorage::File => Err(anyhow::anyhow!("no secret service running")),
},
|backend| match backend {
TokenStorage::Keychain => Ok::<(), anyhow::Error>(()),
TokenStorage::File => Err(anyhow::anyhow!("no secret service running")),
},
)
.unwrap();
assert!(
removed,
"a fallback-backend error must not hide a hit on the preferred one"
);
}
}