#![allow(clippy::module_name_repetitions)]
use std::collections::HashMap;
use std::fmt::Write as _;
use std::process::Stdio;
use anyhow::{anyhow, Result};
use futures::future::join_all;
use secretenv_core::{
with_timeout, Backend, BackendRegistry, BackendStatus, BackendUri, Config,
DEFAULT_CHECK_TIMEOUT,
};
use serde::Serialize;
#[derive(Debug, Clone, Copy, Default)]
#[allow(clippy::struct_excessive_bools)]
pub struct DoctorOpts {
pub json: bool,
pub fix: bool,
pub extensive: bool,
pub trace: bool,
}
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "status", rename_all = "snake_case")]
enum DoctorStatus {
Ok { cli_version: String, identity: String },
NotAuthenticated { hint: String },
CliMissing { cli_name: String, install_hint: String },
Error { message: String },
}
impl From<BackendStatus> for DoctorStatus {
fn from(s: BackendStatus) -> Self {
match s {
BackendStatus::Ok { cli_version, identity } => Self::Ok { cli_version, identity },
BackendStatus::NotAuthenticated { hint } => Self::NotAuthenticated { hint },
BackendStatus::CliMissing { cli_name, install_hint } => {
Self::CliMissing { cli_name, install_hint }
}
BackendStatus::Error { message } => Self::Error { message },
}
}
}
impl DoctorStatus {
const fn variant_key(&self) -> &'static str {
match self {
Self::Ok { .. } => "ok",
Self::NotAuthenticated { .. } => "not_authenticated",
Self::CliMissing { .. } => "cli_missing",
Self::Error { .. } => "error",
}
}
}
#[derive(Debug, Clone, Serialize)]
struct DepthProbe {
uri: String,
#[serde(flatten)]
outcome: DepthOutcome,
}
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "depth_status", rename_all = "snake_case")]
enum DepthOutcome {
Read { entry_count: usize },
Failed { error: String },
}
#[derive(Debug, Clone, Serialize)]
struct DoctorEntry {
instance_name: String,
backend_type: String,
#[serde(flatten)]
status: DoctorStatus,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
depth: Vec<DepthProbe>,
}
#[derive(Debug, Clone, Serialize)]
struct DoctorSummary {
total: usize,
ok: usize,
not_authenticated: usize,
cli_missing: usize,
error: usize,
}
impl DoctorSummary {
fn from_entries(entries: &[DoctorEntry]) -> Self {
let mut s =
Self { total: entries.len(), ok: 0, not_authenticated: 0, cli_missing: 0, error: 0 };
for entry in entries {
match entry.status.variant_key() {
"ok" => s.ok += 1,
"not_authenticated" => s.not_authenticated += 1,
"cli_missing" => s.cli_missing += 1,
"error" => s.error += 1,
_ => {}
}
}
s
}
const fn all_ok(&self) -> bool {
self.ok == self.total
}
}
#[derive(Debug, Clone, Serialize)]
struct RegistrySourceReport {
uri: String,
#[serde(flatten)]
status: DoctorStatus,
}
#[derive(Debug, Clone, Serialize)]
struct RegistryReport {
name: String,
sources: Vec<RegistrySourceReport>,
}
#[derive(Debug, Clone, Serialize)]
struct DoctorReport {
backends: Vec<DoctorEntry>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
registries: Vec<RegistryReport>,
summary: DoctorSummary,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
fix_actions: Vec<FixAction>,
#[serde(default, skip_serializing_if = "Option::is_none")]
otel: Option<OtelStatus>,
#[serde(default, skip_serializing_if = "Option::is_none")]
trace: Option<Vec<TraceSpanRow>>,
}
#[derive(Debug, Clone, Serialize)]
struct TraceSpanRow {
name: String,
duration_ms: u64,
attributes: Vec<(String, String)>,
}
#[derive(Debug, Clone, Serialize)]
struct OtelStatus {
configured: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
endpoint: Option<String>,
service_name: String,
sampler: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
reachability: Option<OtelReachability>,
}
#[derive(Debug, Clone, Serialize)]
struct OtelReachability {
ok: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
rtt_ms: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
error: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
struct FixAction {
instance_name: String,
backend_type: String,
command: Vec<String>,
success: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
spawn_error: Option<String>,
}
#[allow(clippy::too_many_lines)]
pub async fn run_doctor(
config: &Config,
backends: &BackendRegistry,
opts: DoctorOpts,
) -> Result<()> {
let list: Vec<&dyn Backend> = backends.all().collect();
let trace_capture = if opts.trace {
Some(
secretenv_telemetry::LocalTraceCapture::install()
.map_err(|e| anyhow::anyhow!("doctor --trace: {e}"))?,
)
} else {
None
};
let mut statuses = check_all_backends(&list).await;
let mut fix_actions: Vec<FixAction> = Vec::new();
if opts.fix {
let needs_remediation: Vec<usize> = statuses
.iter()
.enumerate()
.filter_map(|(i, s)| matches!(s, BackendStatus::NotAuthenticated { .. }).then_some(i))
.collect();
for i in needs_remediation {
let backend = list[i];
if let Some(action) = remediate(backend).await {
fix_actions.push(action);
}
}
if !fix_actions.is_empty() {
statuses = check_all_backends(&list).await;
}
}
let mut statuses_by_instance: HashMap<String, DoctorStatus> = HashMap::new();
let mut entries: Vec<DoctorEntry> = Vec::with_capacity(list.len());
let backend_count = u64::try_from(statuses.len()).unwrap_or(u64::MAX);
let failure_count =
u64::try_from(statuses.iter().filter(|s| !matches!(s, BackendStatus::Ok { .. })).count())
.unwrap_or(u64::MAX);
for (b, s) in list.iter().zip(statuses) {
let doctor_status: DoctorStatus = s.into();
statuses_by_instance.insert(b.instance_name().to_owned(), doctor_status.clone());
entries.push(DoctorEntry {
instance_name: b.instance_name().to_owned(),
backend_type: b.backend_type().to_owned(),
status: doctor_status,
depth: Vec::new(),
});
}
entries.sort_by(|a, b| a.instance_name.cmp(&b.instance_name));
let registries = {
let (mut registry_span, _registry_guard) =
secretenv_telemetry::SecretEnvSpan::start("secretenv.doctor.registry");
let check_level = if opts.extensive {
secretenv_telemetry::DoctorCheckLevel::Extensive
} else if opts.fix {
secretenv_telemetry::DoctorCheckLevel::Standard
} else {
secretenv_telemetry::DoctorCheckLevel::Quick
};
registry_span
.record_doctor_check_level(check_level)
.record_doctor_backend_count(backend_count)
.record_doctor_failure_count(failure_count);
let mut registry_names: Vec<&String> = config.registries.keys().collect();
registry_names.sort();
let mut registries: Vec<RegistryReport> = Vec::with_capacity(registry_names.len());
for name in registry_names {
let cfg = &config.registries[name];
let mut sources: Vec<RegistrySourceReport> = Vec::with_capacity(cfg.sources.len());
for raw in &cfg.sources {
let status = source_status(raw, &statuses_by_instance);
sources.push(RegistrySourceReport { uri: raw.clone(), status });
}
registries.push(RegistryReport { name: name.clone(), sources });
}
registries
};
if opts.extensive {
run_depth_probes(&list, &mut entries, ®istries).await;
}
let otel = if opts.extensive { Some(probe_otel_status().await) } else { None };
let trace = trace_capture.map(|c| {
c.drain()
.into_iter()
.map(|s| TraceSpanRow {
name: s.name,
duration_ms: s.duration_ms,
attributes: s.attributes,
})
.collect()
});
let summary = DoctorSummary::from_entries(&entries);
let report = DoctorReport { backends: entries, registries, summary, fix_actions, otel, trace };
if opts.json {
println!("{}", serde_json::to_string_pretty(&report)?);
} else {
print!("{}", render_human(&report));
}
if report.summary.all_ok() {
Ok(())
} else {
Err(anyhow!(
"{} of {} backend(s) are not ready — see the report above",
report.summary.total - report.summary.ok,
report.summary.total
))
}
}
async fn check_all_backends(list: &[&dyn Backend]) -> Vec<BackendStatus> {
join_all(list.iter().map(|b| async {
let started = std::time::Instant::now();
let (mut span, _guard) =
secretenv_telemetry::SecretEnvSpan::start("secretenv.doctor.backend");
span.record_backend_type(secretenv_telemetry::BackendType::from_runtime_str(
b.backend_type(),
))
.record_backend_instance(b.instance_name());
let label = format!("{}::check", b.instance_name());
let status = match with_timeout(DEFAULT_CHECK_TIMEOUT, &label, async {
Ok(b.check().await)
})
.await
{
Ok(status) => status,
Err(err) => BackendStatus::Error { message: err.to_string() },
};
let elapsed_ms = u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX);
span.record_duration_ms(elapsed_ms);
status
}))
.await
}
fn remediation_argv(backend_type: &str) -> Option<&'static [&'static str]> {
match backend_type {
"aws-ssm" | "aws-secrets" => Some(&["aws", "sso", "login"]),
"1password" => Some(&["op", "signin"]),
"gcp" => Some(&["gcloud", "auth", "login"]),
"azure" => Some(&["az", "login"]),
"vault" => Some(&["vault", "login"]),
_ => None,
}
}
async fn remediate(backend: &dyn Backend) -> Option<FixAction> {
let argv = remediation_argv(backend.backend_type())?;
let command: Vec<String> = argv.iter().map(|s| (*s).to_owned()).collect();
eprintln!(
"→ remediating '{}' [{}]: {}",
backend.instance_name(),
backend.backend_type(),
command.join(" ")
);
let mut cmd = tokio::process::Command::new(argv[0]);
cmd.args(&argv[1..]);
cmd.stdin(Stdio::inherit());
cmd.stdout(Stdio::inherit());
cmd.stderr(Stdio::inherit());
match cmd.status().await {
Ok(status) => Some(FixAction {
instance_name: backend.instance_name().to_owned(),
backend_type: backend.backend_type().to_owned(),
command,
success: status.success(),
spawn_error: None,
}),
Err(err) => {
let msg = format!("failed to spawn '{}': {err}", argv[0]);
eprintln!(" ✗ {msg}");
Some(FixAction {
instance_name: backend.instance_name().to_owned(),
backend_type: backend.backend_type().to_owned(),
command,
success: false,
spawn_error: Some(msg),
})
}
}
}
async fn run_depth_probes(
list: &[&dyn Backend],
entries: &mut [DoctorEntry],
registries: &[RegistryReport],
) {
for backend in list {
let Some(entry_idx) =
entries.iter().position(|e| e.instance_name == backend.instance_name())
else {
continue;
};
if !matches!(entries[entry_idx].status, DoctorStatus::Ok { .. }) {
continue;
}
let mut seen: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
for reg in registries {
for src in ®.sources {
if let Ok(parsed) = BackendUri::parse(&src.uri) {
if parsed.scheme == backend.instance_name() && seen.insert(src.uri.clone()) {
let outcome = match backend.check_extensive(&parsed).await {
Ok(count) => DepthOutcome::Read { entry_count: count },
Err(err) => DepthOutcome::Failed { error: format!("{err:#}") },
};
entries[entry_idx].depth.push(DepthProbe { uri: src.uri.clone(), outcome });
}
}
}
}
}
}
fn source_status(raw: &str, statuses_by_instance: &HashMap<String, DoctorStatus>) -> DoctorStatus {
match BackendUri::parse(raw) {
Ok(uri) => {
statuses_by_instance.get(&uri.scheme).cloned().unwrap_or_else(|| DoctorStatus::Error {
message: format!(
"backend instance '{}' is not configured in config.toml",
uri.scheme
),
})
}
Err(e) => DoctorStatus::Error { message: format!("source '{raw}' failed to parse: {e}") },
}
}
#[allow(clippy::unwrap_used)]
fn render_human(report: &DoctorReport) -> String {
let mut out = String::new();
writeln!(out, "secretenv doctor").unwrap();
writeln!(out, "================\n").unwrap();
if !report.fix_actions.is_empty() {
writeln!(out, "Remediation actions ({})", report.fix_actions.len()).unwrap();
for action in &report.fix_actions {
let tick = if action.success { "✓" } else { "✗" };
writeln!(
out,
" {tick} {} [{}] — {}",
action.instance_name,
action.backend_type,
action.command.join(" ")
)
.unwrap();
if let Some(err) = &action.spawn_error {
writeln!(out, " → {err}").unwrap();
}
}
writeln!(out).unwrap();
}
if report.backends.is_empty() {
writeln!(out, "No backends configured in config.toml.").unwrap();
return out;
}
writeln!(out, "Backends ({} configured)", report.summary.total).unwrap();
let last = report.backends.len() - 1;
for (i, entry) in report.backends.iter().enumerate() {
let branch = if i == last { "└──" } else { "├──" };
let indent = if i == last { " " } else { "│ " };
writeln!(out, "{branch} {} [{}]", entry.instance_name, entry.backend_type).unwrap();
render_status_block(&mut out, indent, &entry.status);
if !entry.depth.is_empty() {
render_depth_block(&mut out, indent, &entry.depth);
}
}
if !report.registries.is_empty() {
writeln!(out).unwrap();
render_registries(&mut out, &report.registries);
}
if let Some(otel) = &report.otel {
writeln!(out).unwrap();
render_otel(&mut out, otel);
}
if let Some(trace) = &report.trace {
writeln!(out).unwrap();
render_trace(&mut out, trace);
}
writeln!(out).unwrap();
write!(out, "Summary: {}/{} OK", report.summary.ok, report.summary.total).unwrap();
if report.summary.not_authenticated > 0 {
write!(out, ", {} not authenticated", report.summary.not_authenticated).unwrap();
}
if report.summary.cli_missing > 0 {
write!(out, ", {} missing CLI", report.summary.cli_missing).unwrap();
}
if report.summary.error > 0 {
write!(out, ", {} error", report.summary.error).unwrap();
}
writeln!(out).unwrap();
out
}
#[allow(clippy::unwrap_used)]
fn render_otel(out: &mut String, otel: &OtelStatus) {
writeln!(out, "-- OpenTelemetry --------------------------------------------------").unwrap();
if !otel.configured {
writeln!(out, " No exporter configured. Set OTEL_EXPORTER_OTLP_ENDPOINT to enable.")
.unwrap();
writeln!(out, " Docs: docs/reference/opentelemetry.md").unwrap();
return;
}
if let Some(endpoint) = &otel.endpoint {
writeln!(out, " Exporter endpoint: {endpoint}").unwrap();
}
if let Some(reach) = &otel.reachability {
let summary = if reach.ok {
reach.rtt_ms.map_or_else(|| "ok".to_owned(), |ms| format!("ok (TCP {ms}ms)"))
} else {
reach.error.clone().unwrap_or_else(|| "unreachable".to_owned())
};
writeln!(out, " Connection check: {summary}").unwrap();
}
writeln!(out, " Service name: {}", otel.service_name).unwrap();
writeln!(out, " Sampler: {}", otel.sampler).unwrap();
}
#[allow(clippy::unwrap_used)]
fn render_trace(out: &mut String, spans: &[TraceSpanRow]) {
writeln!(out, "-- Trace (local capture) ------------------------------------------").unwrap();
if spans.is_empty() {
writeln!(out, " No spans captured during this doctor pass.").unwrap();
return;
}
for s in spans {
let label = backend_label_from_attrs(&s.attributes);
writeln!(out, " {:<28} {:<24} {:>5}ms", s.name, label, s.duration_ms).unwrap();
}
let mut durations: Vec<u64> = spans.iter().map(|s| s.duration_ms).collect();
durations.sort_unstable();
let p95 = percentile(&durations, 95);
if let Some(slowest) = spans.iter().max_by_key(|s| s.duration_ms) {
writeln!(
out,
" P95 latency (this run): {p95}ms Slowest: {}",
backend_label_from_attrs(&slowest.attributes),
)
.unwrap();
}
}
fn backend_label_from_attrs(attrs: &[(String, String)]) -> String {
let bt = attrs.iter().find(|(k, _)| k == "secretenv.backend.type").map(|(_, v)| v.as_str());
let bi =
attrs.iter().find(|(k, _)| k == "secretenv.backend.instance_name").map(|(_, v)| v.as_str());
match (bt, bi) {
(Some(t), Some(i)) => format!("{t}/{i}"),
(Some(t), None) => t.to_owned(),
(None, Some(i)) => i.to_owned(),
(None, None) => "-".to_owned(),
}
}
fn percentile(sorted: &[u64], pct: usize) -> u64 {
if sorted.is_empty() {
return 0;
}
let rank = (pct * sorted.len()).div_ceil(100).saturating_sub(1);
sorted[rank.min(sorted.len() - 1)]
}
async fn probe_otel_status() -> OtelStatus {
let endpoint_env = std::env::var("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT")
.ok()
.or_else(|| std::env::var("OTEL_EXPORTER_OTLP_ENDPOINT").ok());
let exporter_explicit = std::env::var("OTEL_TRACES_EXPORTER")
.ok()
.or_else(|| std::env::var("OTEL_METRICS_EXPORTER").ok())
.filter(|v| matches!(v.as_str(), "otlp" | "console"));
let configured = endpoint_env.is_some() || exporter_explicit.is_some();
let service_name =
std::env::var("OTEL_SERVICE_NAME").unwrap_or_else(|_| "secretenv".to_owned());
let sampler = "parentbased_always_on (mutation-non-droppable)".to_owned();
let reachability = if let Some(ep) = endpoint_env.as_deref() {
Some(probe_otel_reachability(ep).await)
} else {
None
};
OtelStatus { configured, endpoint: endpoint_env, service_name, sampler, reachability }
}
async fn probe_otel_reachability(endpoint: &str) -> OtelReachability {
let protocol_default_port =
match std::env::var("OTEL_EXPORTER_OTLP_PROTOCOL").as_deref().unwrap_or("") {
"http/protobuf" | "http/json" => 4318,
_ => 4317,
};
let host_port = parse_endpoint_host_port(endpoint, protocol_default_port);
let started = std::time::Instant::now();
let connect_fut = tokio::net::TcpStream::connect(&host_port);
match tokio::time::timeout(std::time::Duration::from_secs(1), connect_fut).await {
Ok(Ok(_stream)) => OtelReachability {
ok: true,
rtt_ms: Some(u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX)),
error: None,
},
Ok(Err(e)) => OtelReachability {
ok: false,
rtt_ms: None,
error: Some(format!("TCP connect failed: {e}")),
},
Err(_) => OtelReachability {
ok: false,
rtt_ms: None,
error: Some("TCP connect timed out after 1s".to_owned()),
},
}
}
fn parse_endpoint_host_port(endpoint: &str, default_port: u16) -> String {
let after_scheme = endpoint.split_once("://").map_or(endpoint, |(_, rest)| rest);
let host_port = after_scheme.split('/').next().unwrap_or(after_scheme);
if host_port.contains(':') {
host_port.to_owned()
} else {
format!("{host_port}:{default_port}")
}
}
#[allow(clippy::unwrap_used)]
fn render_registries(out: &mut String, registries: &[RegistryReport]) {
writeln!(out, "Registries ({} configured)", registries.len()).unwrap();
for reg in registries {
writeln!(out, " {}", reg.name).unwrap();
for source in ®.sources {
let tick = if matches!(source.status, DoctorStatus::Ok { .. }) { "✓" } else { "✗" };
let suffix = source_status_suffix(&source.status);
writeln!(out, " {tick} {} {suffix}", source.uri).unwrap();
if let Some(hint) = source_status_hint(&source.status) {
writeln!(out, " → {hint}").unwrap();
}
}
}
}
#[allow(clippy::unwrap_used)]
fn render_depth_block(out: &mut String, indent: &str, depth: &[DepthProbe]) {
writeln!(
out,
"{indent} depth probe ({} {})",
depth.len(),
pluralize("source", "sources", depth.len())
)
.unwrap();
for probe in depth {
match &probe.outcome {
DepthOutcome::Read { entry_count } => {
writeln!(
out,
"{indent} ✓ {} {} {} readable",
probe.uri,
entry_count,
pluralize("alias", "aliases", *entry_count)
)
.unwrap();
}
DepthOutcome::Failed { error } => {
writeln!(out, "{indent} ✗ {} read failed", probe.uri).unwrap();
writeln!(out, "{indent} → {error}").unwrap();
}
}
}
}
const fn pluralize(singular: &'static str, plural: &'static str, n: usize) -> &'static str {
if n == 1 {
singular
} else {
plural
}
}
fn source_status_suffix(status: &DoctorStatus) -> String {
match status {
DoctorStatus::Ok { .. } => "reachable".to_owned(),
DoctorStatus::NotAuthenticated { .. } => "backend not authenticated".to_owned(),
DoctorStatus::CliMissing { cli_name, .. } => format!("backend CLI '{cli_name}' missing"),
DoctorStatus::Error { .. } => "backend error".to_owned(),
}
}
fn source_status_hint(status: &DoctorStatus) -> Option<&str> {
match status {
DoctorStatus::Ok { .. } => None,
DoctorStatus::NotAuthenticated { hint } => Some(hint),
DoctorStatus::CliMissing { install_hint, .. } => Some(install_hint),
DoctorStatus::Error { message } => Some(message),
}
}
#[allow(clippy::unwrap_used)]
fn render_status_block(out: &mut String, indent: &str, status: &DoctorStatus) {
match status {
DoctorStatus::Ok { cli_version, identity } => {
writeln!(out, "{indent}✓ ready").unwrap();
writeln!(out, "{indent} cli: {cli_version}").unwrap();
writeln!(out, "{indent} identity: {identity}").unwrap();
}
DoctorStatus::NotAuthenticated { hint } => {
writeln!(out, "{indent}✗ not authenticated").unwrap();
writeln!(out, "{indent} {hint}").unwrap();
}
DoctorStatus::CliMissing { cli_name, install_hint } => {
writeln!(out, "{indent}✗ CLI '{cli_name}' not found on PATH").unwrap();
writeln!(out, "{indent} install: {install_hint}").unwrap();
}
DoctorStatus::Error { message } => {
writeln!(out, "{indent}✗ error").unwrap();
writeln!(out, "{indent} {message}").unwrap();
}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
fn entry(instance: &str, ty: &str, status: DoctorStatus) -> DoctorEntry {
DoctorEntry {
instance_name: instance.to_owned(),
backend_type: ty.to_owned(),
status,
depth: Vec::new(),
}
}
fn entry_with_depth(
instance: &str,
ty: &str,
status: DoctorStatus,
depth: Vec<DepthProbe>,
) -> DoctorEntry {
DoctorEntry {
instance_name: instance.to_owned(),
backend_type: ty.to_owned(),
status,
depth,
}
}
fn report(entries: Vec<DoctorEntry>) -> DoctorReport {
let summary = DoctorSummary::from_entries(&entries);
DoctorReport {
backends: entries,
registries: Vec::new(),
summary,
fix_actions: Vec::new(),
otel: None,
trace: None,
}
}
fn report_with_registries(
entries: Vec<DoctorEntry>,
registries: Vec<RegistryReport>,
) -> DoctorReport {
let summary = DoctorSummary::from_entries(&entries);
DoctorReport {
backends: entries,
registries,
summary,
fix_actions: Vec::new(),
otel: None,
trace: None,
}
}
fn report_with_fix(entries: Vec<DoctorEntry>, fix_actions: Vec<FixAction>) -> DoctorReport {
let summary = DoctorSummary::from_entries(&entries);
DoctorReport {
backends: entries,
registries: Vec::new(),
summary,
fix_actions,
otel: None,
trace: None,
}
}
#[test]
fn status_from_backend_status_ok() {
let s: DoctorStatus =
BackendStatus::Ok { cli_version: "aws-cli/2".into(), identity: "x".into() }.into();
assert_eq!(s.variant_key(), "ok");
}
#[test]
fn status_from_backend_status_not_authenticated() {
let s: DoctorStatus = BackendStatus::NotAuthenticated { hint: "op signin".into() }.into();
assert_eq!(s.variant_key(), "not_authenticated");
}
#[test]
fn status_from_backend_status_cli_missing() {
let s: DoctorStatus = BackendStatus::CliMissing {
cli_name: "aws".into(),
install_hint: "brew install awscli".into(),
}
.into();
assert_eq!(s.variant_key(), "cli_missing");
}
#[test]
fn status_from_backend_status_error() {
let s: DoctorStatus = BackendStatus::Error { message: "boom".into() }.into();
assert_eq!(s.variant_key(), "error");
}
#[test]
fn summary_counts_each_variant() {
let entries = vec![
entry("a", "local", DoctorStatus::Ok { cli_version: "v".into(), identity: "i".into() }),
entry("b", "aws-ssm", DoctorStatus::NotAuthenticated { hint: "h".into() }),
entry(
"c",
"op",
DoctorStatus::CliMissing { cli_name: "op".into(), install_hint: "hint".into() },
),
entry("d", "local", DoctorStatus::Error { message: "m".into() }),
entry("e", "local", DoctorStatus::Ok { cli_version: "v".into(), identity: "i".into() }),
];
let s = DoctorSummary::from_entries(&entries);
assert_eq!(s.total, 5);
assert_eq!(s.ok, 2);
assert_eq!(s.not_authenticated, 1);
assert_eq!(s.cli_missing, 1);
assert_eq!(s.error, 1);
assert!(!s.all_ok());
}
#[test]
fn summary_all_ok_when_every_backend_ok() {
let entries = vec![
entry("a", "local", DoctorStatus::Ok { cli_version: "v".into(), identity: "i".into() }),
entry("b", "local", DoctorStatus::Ok { cli_version: "v".into(), identity: "i".into() }),
];
let s = DoctorSummary::from_entries(&entries);
assert!(s.all_ok());
}
#[test]
fn render_human_includes_tree_and_ticks() {
let r = report(vec![
entry(
"local",
"local",
DoctorStatus::Ok { cli_version: "local".into(), identity: "filesystem".into() },
),
entry(
"aws-ssm-prod",
"aws-ssm",
DoctorStatus::NotAuthenticated { hint: "aws sso login".into() },
),
]);
let out = render_human(&r);
assert!(out.contains("Backends (2 configured)"));
assert!(out.contains("├──"));
assert!(out.contains("└──"));
assert!(out.contains("✓ ready"));
assert!(out.contains("✗ not authenticated"));
assert!(out.contains("aws sso login"));
assert!(out.contains("Summary: 1/2 OK, 1 not authenticated"));
}
#[test]
fn render_human_reports_no_backends() {
let r = report(vec![]);
let out = render_human(&r);
assert!(out.contains("No backends configured"));
}
#[test]
fn render_human_cli_missing_shows_install_hint() {
let r = report(vec![entry(
"aws-ssm",
"aws-ssm",
DoctorStatus::CliMissing {
cli_name: "aws".into(),
install_hint: "brew install awscli".into(),
},
)]);
let out = render_human(&r);
assert!(out.contains("CLI 'aws' not found"));
assert!(out.contains("brew install awscli"));
}
#[test]
fn json_output_has_stable_shape() {
let r = report(vec![
entry(
"local",
"local",
DoctorStatus::Ok { cli_version: "local".into(), identity: "filesystem".into() },
),
entry(
"aws-ssm-prod",
"aws-ssm",
DoctorStatus::NotAuthenticated { hint: "aws sso login".into() },
),
]);
let json = serde_json::to_value(&r).unwrap();
assert!(json.get("backends").is_some());
assert!(json.get("summary").is_some());
let summary = &json["summary"];
assert_eq!(summary["total"], 2);
assert_eq!(summary["ok"], 1);
assert_eq!(summary["not_authenticated"], 1);
let ok = &json["backends"][0];
assert_eq!(ok["instance_name"], "local");
assert_eq!(ok["backend_type"], "local");
assert_eq!(ok["status"], "ok");
assert_eq!(ok["cli_version"], "local");
assert_eq!(ok["identity"], "filesystem");
let na = &json["backends"][1];
assert_eq!(na["status"], "not_authenticated");
assert_eq!(na["hint"], "aws sso login");
assert!(json.get("fix_actions").is_none());
assert!(ok.get("depth").is_none());
}
fn src(uri: &str, status: DoctorStatus) -> RegistrySourceReport {
RegistrySourceReport { uri: uri.to_owned(), status }
}
#[test]
fn human_output_omits_registries_section_when_empty() {
let r = report(vec![entry(
"local",
"local",
DoctorStatus::Ok { cli_version: "v".into(), identity: "i".into() },
)]);
let out = render_human(&r);
assert!(!out.contains("Registries"));
}
#[test]
fn human_output_renders_single_source_registry() {
let r = report_with_registries(
vec![entry(
"local",
"local",
DoctorStatus::Ok { cli_version: "v".into(), identity: "i".into() },
)],
vec![RegistryReport {
name: "default".into(),
sources: vec![src(
"local:///tmp/r.toml",
DoctorStatus::Ok { cli_version: "v".into(), identity: "i".into() },
)],
}],
);
let out = render_human(&r);
assert!(out.contains("Registries (1 configured)"));
assert!(out.contains(" default"));
assert!(out.contains("✓ local:///tmp/r.toml"));
assert!(out.contains("reachable"));
}
#[test]
fn human_output_renders_cascade_with_mixed_source_status() {
let r = report_with_registries(
vec![
entry(
"aws-ssm-dev",
"aws-ssm",
DoctorStatus::Ok { cli_version: "v".into(), identity: "i".into() },
),
entry(
"aws-ssm-platform",
"aws-ssm",
DoctorStatus::NotAuthenticated {
hint: "aws sso login --profile platform".into(),
},
),
],
vec![RegistryReport {
name: "dev".into(),
sources: vec![
src(
"aws-ssm-dev:///secretenv/dev-registry",
DoctorStatus::Ok { cli_version: "v".into(), identity: "i".into() },
),
src(
"aws-ssm-platform:///secretenv/org-registry",
DoctorStatus::NotAuthenticated {
hint: "aws sso login --profile platform".into(),
},
),
],
}],
);
let out = render_human(&r);
assert!(
out.contains("✓ aws-ssm-dev:///secretenv/dev-registry"),
"expected tick on reachable source:\n{out}"
);
assert!(
out.contains("✗ aws-ssm-platform:///secretenv/org-registry"),
"expected cross on unreachable source:\n{out}"
);
assert!(out.contains("backend not authenticated"), "suffix: {out}");
assert!(out.contains("→ aws sso login --profile platform"), "hint rendered:\n{out}");
}
#[test]
fn human_output_handles_unparseable_source_gracefully() {
let r = report_with_registries(
vec![entry(
"local",
"local",
DoctorStatus::Ok { cli_version: "v".into(), identity: "i".into() },
)],
vec![RegistryReport {
name: "broken".into(),
sources: vec![src(
"not-a-uri",
DoctorStatus::Error {
message: "source 'not-a-uri' failed to parse: malformed input".into(),
},
)],
}],
);
let out = render_human(&r);
assert!(out.contains("✗ not-a-uri"));
assert!(out.contains("backend error"));
assert!(out.contains("failed to parse"));
}
#[test]
fn json_output_includes_registries_section_when_present() {
let r = report_with_registries(
vec![entry(
"aws-ssm-prod",
"aws-ssm",
DoctorStatus::NotAuthenticated { hint: "aws sso login".into() },
)],
vec![RegistryReport {
name: "prod".into(),
sources: vec![src(
"aws-ssm-prod:///secretenv/prod-reg",
DoctorStatus::NotAuthenticated { hint: "aws sso login".into() },
)],
}],
);
let json = serde_json::to_value(&r).unwrap();
let registries = &json["registries"];
assert!(registries.is_array());
assert_eq!(registries[0]["name"], "prod");
let first_source = ®istries[0]["sources"][0];
assert_eq!(first_source["uri"], "aws-ssm-prod:///secretenv/prod-reg");
assert_eq!(first_source["status"], "not_authenticated");
assert_eq!(first_source["hint"], "aws sso login");
}
#[test]
fn json_output_omits_registries_key_when_empty() {
let r = report(vec![entry(
"local",
"local",
DoctorStatus::Ok { cli_version: "v".into(), identity: "i".into() },
)]);
let json = serde_json::to_value(&r).unwrap();
assert!(json.get("registries").is_none(), "registries key should be omitted: {json}");
}
#[test]
fn source_status_uses_cached_backend_status_on_parse_ok() {
let mut m = HashMap::new();
m.insert(
"aws-ssm-prod".to_owned(),
DoctorStatus::Ok { cli_version: "v".into(), identity: "i".into() },
);
let got = source_status("aws-ssm-prod:///some/path", &m);
assert_eq!(got.variant_key(), "ok");
}
#[test]
fn source_status_errors_when_scheme_not_registered() {
let m: HashMap<String, DoctorStatus> = HashMap::new();
let got = source_status("aws-ssm-prod:///path", &m);
match got {
DoctorStatus::Error { message } => {
assert!(
message.contains("aws-ssm-prod") && message.contains("not configured"),
"message: {message}"
);
}
other => panic!("expected Error, got {other:?}"),
}
}
#[test]
fn source_status_errors_on_unparseable_uri() {
let m: HashMap<String, DoctorStatus> = HashMap::new();
let got = source_status("not-a-uri-at-all", &m);
match got {
DoctorStatus::Error { message } => {
assert!(message.contains("not-a-uri-at-all"), "message: {message}");
assert!(message.contains("failed to parse"), "message: {message}");
}
other => panic!("expected Error, got {other:?}"),
}
}
#[test]
fn remediation_argv_known_backends() {
assert_eq!(remediation_argv("aws-ssm"), Some(&["aws", "sso", "login"][..]));
assert_eq!(remediation_argv("aws-secrets"), Some(&["aws", "sso", "login"][..]));
assert_eq!(remediation_argv("1password"), Some(&["op", "signin"][..]));
assert_eq!(remediation_argv("gcp"), Some(&["gcloud", "auth", "login"][..]));
assert_eq!(remediation_argv("azure"), Some(&["az", "login"][..]));
assert_eq!(remediation_argv("vault"), Some(&["vault", "login"][..]));
}
#[test]
fn remediation_argv_local_is_none() {
assert_eq!(remediation_argv("local"), None);
}
#[test]
fn remediation_argv_unknown_backend_is_none() {
assert_eq!(remediation_argv("definitely-not-real"), None);
}
#[test]
fn render_human_includes_fix_actions_section() {
let entries = vec![entry(
"1password-personal",
"1password",
DoctorStatus::Ok { cli_version: "2".into(), identity: "me".into() },
)];
let actions = vec![FixAction {
instance_name: "1password-personal".into(),
backend_type: "1password".into(),
command: vec!["op".into(), "signin".into()],
success: true,
spawn_error: None,
}];
let r = report_with_fix(entries, actions);
let out = render_human(&r);
assert!(out.contains("Remediation actions (1)"), "section header: {out}");
assert!(out.contains("✓ 1password-personal [1password] — op signin"), "row: {out}");
}
#[test]
fn render_human_fix_action_failure_includes_spawn_error() {
let entries = vec![entry(
"vault-prod",
"vault",
DoctorStatus::NotAuthenticated { hint: "vault login".into() },
)];
let actions = vec![FixAction {
instance_name: "vault-prod".into(),
backend_type: "vault".into(),
command: vec!["vault".into(), "login".into()],
success: false,
spawn_error: Some("failed to spawn 'vault': No such file or directory".into()),
}];
let r = report_with_fix(entries, actions);
let out = render_human(&r);
assert!(out.contains("✗ vault-prod"));
assert!(out.contains("→ failed to spawn 'vault'"), "spawn-error indented: {out}");
}
#[test]
fn json_output_includes_fix_actions_when_set() {
let entries = vec![entry(
"azure-prod",
"azure",
DoctorStatus::Ok { cli_version: "2".into(), identity: "me".into() },
)];
let actions = vec![FixAction {
instance_name: "azure-prod".into(),
backend_type: "azure".into(),
command: vec!["az".into(), "login".into()],
success: true,
spawn_error: None,
}];
let r = report_with_fix(entries, actions);
let json = serde_json::to_value(&r).unwrap();
let fix = json["fix_actions"].as_array().expect("fix_actions array");
assert_eq!(fix.len(), 1);
assert_eq!(fix[0]["instance_name"], "azure-prod");
assert_eq!(fix[0]["backend_type"], "azure");
assert_eq!(fix[0]["command"], serde_json::json!(["az", "login"]));
assert_eq!(fix[0]["success"], true);
assert!(fix[0].get("spawn_error").is_none());
}
#[test]
fn render_human_includes_depth_block_with_alias_count() {
let depth = vec![DepthProbe {
uri: "aws-ssm-prod:///secretenv/prod-registry".into(),
outcome: DepthOutcome::Read { entry_count: 12 },
}];
let r = report(vec![entry_with_depth(
"aws-ssm-prod",
"aws-ssm",
DoctorStatus::Ok { cli_version: "v".into(), identity: "i".into() },
depth,
)]);
let out = render_human(&r);
assert!(out.contains("depth probe (1 source)"), "header: {out}");
assert!(out.contains("✓ aws-ssm-prod:///secretenv/prod-registry"), "uri line: {out}");
assert!(out.contains("12 aliases readable"), "count + readable: {out}");
}
#[test]
fn render_human_depth_failure_includes_error() {
let depth = vec![DepthProbe {
uri: "vault-prod:///kv/registry".into(),
outcome: DepthOutcome::Failed {
error: "permission denied: user lacks 'read' on secret/kv/registry".into(),
},
}];
let r = report(vec![entry_with_depth(
"vault-prod",
"vault",
DoctorStatus::Ok { cli_version: "v".into(), identity: "i".into() },
depth,
)]);
let out = render_human(&r);
assert!(out.contains("✗ vault-prod:///kv/registry"));
assert!(out.contains("→ permission denied"), "error line: {out}");
}
#[test]
fn render_human_depth_block_pluralizes_correctly() {
let depth = vec![
DepthProbe { uri: "x:///a".into(), outcome: DepthOutcome::Read { entry_count: 1 } },
DepthProbe { uri: "x:///b".into(), outcome: DepthOutcome::Read { entry_count: 0 } },
];
let r = report(vec![entry_with_depth(
"x",
"local",
DoctorStatus::Ok { cli_version: "v".into(), identity: "i".into() },
depth,
)]);
let out = render_human(&r);
assert!(out.contains("depth probe (2 sources)"), "plural sources: {out}");
assert!(out.contains("1 alias readable"), "singular alias: {out}");
assert!(out.contains("0 aliases readable"), "zero is plural: {out}");
}
#[test]
fn json_output_includes_depth_array_when_populated() {
let depth = vec![
DepthProbe {
uri: "gcp-prod:///registry".into(),
outcome: DepthOutcome::Read { entry_count: 7 },
},
DepthProbe {
uri: "gcp-prod:///broken".into(),
outcome: DepthOutcome::Failed { error: "denied".into() },
},
];
let r = report(vec![entry_with_depth(
"gcp-prod",
"gcp",
DoctorStatus::Ok { cli_version: "v".into(), identity: "i".into() },
depth,
)]);
let json = serde_json::to_value(&r).unwrap();
let probes = json["backends"][0]["depth"].as_array().expect("depth array");
assert_eq!(probes.len(), 2);
assert_eq!(probes[0]["uri"], "gcp-prod:///registry");
assert_eq!(probes[0]["depth_status"], "read");
assert_eq!(probes[0]["entry_count"], 7);
assert_eq!(probes[1]["depth_status"], "failed");
assert_eq!(probes[1]["error"], "denied");
}
#[test]
fn json_omits_depth_key_when_empty() {
let r = report(vec![entry(
"local",
"local",
DoctorStatus::Ok { cli_version: "v".into(), identity: "i".into() },
)]);
let json = serde_json::to_value(&r).unwrap();
assert!(json["backends"][0].get("depth").is_none(), "depth omitted: {json}");
}
}