fabro/lib/crates/fabro-util/src/exit.rs
Bryan Helmkamp 92a09adb20
feat(cli): suggest fabro auth login on auth-required errors
Unauthenticated commands surfaced only `error: Authentication required.`
with no remediation. Add a cyan-bold `hint:` line pointing at
`fabro auth login` in the top-level error printer, keyed off
`ExitClass::AuthRequired` so it covers every command that hits the
server (run, exec, ps, system info, etc.). Suppressed when `--json` is
set.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 10:49:22 -04:00

128 lines
3.2 KiB
Rust

use anyhow::Error;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ExitClass {
AuthRequired,
}
// Keep the wrapper transparent so existing stderr remains unchanged while the
// exit class stays discoverable via downcast.
struct Classified {
class: ExitClass,
inner: Error,
}
impl Classified {
fn class(&self) -> ExitClass {
self.class
}
}
impl std::fmt::Debug for Classified {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
std::fmt::Debug::fmt(&self.inner, f)
}
}
impl std::fmt::Display for Classified {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
std::fmt::Display::fmt(&self.inner, f)
}
}
impl std::error::Error for Classified {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
self.inner.source()
}
}
pub trait ErrorExt {
fn classify(self, class: ExitClass) -> Error;
}
impl ErrorExt for Error {
fn classify(self, class: ExitClass) -> Error {
Self::new(Classified { class, inner: self })
}
}
pub fn exit_code_for(err: &Error) -> i32 {
err.chain()
.find_map(|cause| cause.downcast_ref::<Classified>())
.map_or(1, |classified| match classified.class() {
ExitClass::AuthRequired => 4,
})
}
pub fn exit_class_for(err: &Error) -> Option<ExitClass> {
err.chain()
.find_map(|cause| cause.downcast_ref::<Classified>())
.map(Classified::class)
}
#[cfg(test)]
mod tests {
use anyhow::anyhow;
use super::{ErrorExt, ExitClass, exit_class_for, exit_code_for};
#[test]
fn unclassified_errors_default_to_exit_1() {
assert_eq!(exit_code_for(&anyhow!("boom")), 1);
}
#[test]
fn classified_errors_map_to_exit_4() {
let err = anyhow!("boom").classify(ExitClass::AuthRequired);
assert_eq!(exit_code_for(&err), 4);
}
#[test]
fn classification_keeps_display_transparent() {
assert_eq!(
anyhow!("boom")
.classify(ExitClass::AuthRequired)
.to_string(),
anyhow!("boom").to_string()
);
}
#[test]
fn classification_keeps_chain_length_transparent() {
assert_eq!(
anyhow!("boom")
.classify(ExitClass::AuthRequired)
.chain()
.count(),
anyhow!("boom").chain().count()
);
}
#[test]
fn buried_classification_still_resolves() {
let err = anyhow!("boom")
.context("while x")
.classify(ExitClass::AuthRequired)
.context("while y");
assert_eq!(exit_code_for(&err), 4);
}
#[test]
fn exit_class_for_returns_none_for_unclassified() {
assert_eq!(exit_class_for(&anyhow!("boom")), None);
}
#[test]
fn exit_class_for_returns_auth_required() {
let err = anyhow!("boom").classify(ExitClass::AuthRequired);
assert_eq!(exit_class_for(&err), Some(ExitClass::AuthRequired));
}
#[test]
fn exit_class_for_resolves_through_context() {
let err = anyhow!("boom")
.classify(ExitClass::AuthRequired)
.context("while y");
assert_eq!(exit_class_for(&err), Some(ExitClass::AuthRequired));
}
}