use anyhow::Result;
use atm_core::doctor::DoctorQuery;
use atm_core::home;
use clap::Args;
use crate::composition::CliComposition;
use crate::observability::CliObservability;
use crate::output;
#[derive(Debug, Args)]
pub struct DoctorCommand {
#[arg(long, help = "Override the resolved team for the doctor check.")]
team: Option<String>,
#[arg(long, help = "Emit the doctor report as JSON.")]
json: bool,
}
impl DoctorCommand {
pub fn run(self, observability: &CliObservability) -> Result<()> {
let current_dir = std::env::current_dir()?;
let home_dir = home::atm_home()?;
let composition = CliComposition::bootstrap("doctor", observability)?;
let json = self.json;
let report = self.execute(&composition, home_dir, current_dir)?;
let has_errors = report.has_errors();
output::print_doctor_result(&report, json)?;
if has_errors {
std::process::exit(1);
}
Ok(())
}
fn build_query(
&self,
home_dir: std::path::PathBuf,
current_dir: std::path::PathBuf,
) -> Result<DoctorQuery> {
let team_override = self
.team
.as_ref()
.map(|value| {
value.parse::<atm_core::types::TeamName>().map_err(|error| {
error.with_recovery(
"Use `--team <team>` with a valid ATM team name when running `atm doctor`.",
)
})
})
.transpose()?;
Ok(DoctorQuery {
home_dir,
current_dir,
team_override,
})
}
fn execute(
self,
composition: &CliComposition,
home_dir: std::path::PathBuf,
current_dir: std::path::PathBuf,
) -> Result<atm_core::doctor::DoctorReport> {
composition
.doctor(self.build_query(home_dir, current_dir)?)
.map_err(Into::into)
}
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use atm_core::doctor::{
DoctorEnvironmentVisibility, DoctorFinding, DoctorReport, DoctorStatus, DoctorSummary,
};
use atm_core::error::AtmError;
use atm_core::observability::{AtmObservabilityHealth, AtmObservabilityHealthState};
use atm_core::protocol::{ProtocolErrorEnvelope, RequestEnvelope, ResponseEnvelope};
use atm_core::transport::testing::FakeClientTransport;
use tempfile::TempDir;
use super::DoctorCommand;
use crate::composition::CliComposition;
use crate::observability::CliObservability;
fn test_paths() -> (TempDir, std::path::PathBuf, std::path::PathBuf) {
let tempdir = TempDir::new().expect("tempdir");
let home_dir = tempdir.path().join("home");
let current_dir = tempdir.path().join("cwd");
(tempdir, home_dir, current_dir)
}
fn healthy_report() -> DoctorReport {
DoctorReport {
summary: DoctorSummary {
status: DoctorStatus::Healthy,
message: "ok".to_string(),
info_count: 0,
warning_count: 0,
error_count: 0,
},
findings: Vec::<DoctorFinding>::new(),
recommendations: Vec::new(),
environment: DoctorEnvironmentVisibility {
atm_home: None,
atm_team: None,
atm_identity: None,
team_override: None,
},
member_roster: None,
observability: AtmObservabilityHealth {
active_log_path: None,
logging_state: AtmObservabilityHealthState::Healthy,
query_state: Some(AtmObservabilityHealthState::Healthy),
maintenance: None,
diagnostic: None,
detail: None,
},
runtime_status: None,
bootstrap_trace: None,
}
}
#[test]
fn build_query_preserves_team_override() {
let command = DoctorCommand {
team: Some("test-team".to_string()),
json: true,
};
let (_tempdir, home_dir, current_dir) = test_paths();
let query = command.build_query(home_dir, current_dir).expect("query");
assert_eq!(
query.team_override.as_ref().map(|value| value.as_str()),
Some("test-team")
);
}
#[test]
fn build_query_adds_recovery_for_invalid_team_override() {
let command = DoctorCommand {
team: Some("bad team".to_string()),
json: false,
};
let (_tempdir, home_dir, current_dir) = test_paths();
let error = command
.build_query(home_dir, current_dir)
.expect_err("invalid team override should fail");
let atm_error = error.downcast_ref::<AtmError>().expect("atm error");
assert_eq!(
atm_error.recovery.as_deref(),
Some("Use `--team <team>` with a valid ATM team name when running `atm doctor`.")
);
}
#[test]
fn execute_accepts_fake_transport_healthy_report() {
let transport = Arc::new(FakeClientTransport::new(|request| match request {
RequestEnvelope::Doctor(_) => Ok(ResponseEnvelope::Doctor(healthy_report())),
other => panic!("unexpected request: {other:?}"),
}));
let observability = CliObservability::fallback();
let composition = CliComposition::from_transport(transport, &observability);
let command = DoctorCommand {
team: Some("test-team".to_string()),
json: false,
};
let (_tempdir, home_dir, current_dir) = test_paths();
let report = command
.execute(&composition, home_dir, current_dir)
.expect("report");
assert_eq!(report.summary.status, DoctorStatus::Healthy);
}
#[test]
fn execute_surfaces_fake_transport_doctor_error() {
let transport = Arc::new(FakeClientTransport::new(|request| match request {
RequestEnvelope::Doctor(_) => {
Ok(ResponseEnvelope::Error(ProtocolErrorEnvelope::from_error(
&AtmError::daemon_unavailable("synthetic doctor transport failure"),
)))
}
other => panic!("unexpected request: {other:?}"),
}));
let observability = CliObservability::fallback();
let composition = CliComposition::from_transport(transport, &observability);
let command = DoctorCommand {
team: None,
json: false,
};
let (_tempdir, home_dir, current_dir) = test_paths();
let error = command
.execute(&composition, home_dir, current_dir)
.expect_err("doctor error");
let atm_error = error
.downcast_ref::<AtmError>()
.expect("doctor error should preserve AtmError");
assert_eq!(
atm_error.code,
atm_core::error_codes::AtmErrorCode::DaemonUnavailable
);
assert!(
error
.to_string()
.contains("synthetic doctor transport failure")
);
}
}