commit 9c18ca6145da99a828a29b39fdce53b3a5e3d2f5
parent a86cae13aad8bac92cb28f55c01e94c11ec5cf88
Author: triesap <tyson@radroots.org>
Date: Tue, 7 Apr 2026 03:22:30 +0000
land config and doctor operator entrypoints
Diffstat:
7 files changed, 569 insertions(+), 39 deletions(-)
diff --git a/src/commands/doctor.rs b/src/commands/doctor.rs
@@ -0,0 +1,254 @@
+use crate::domain::runtime::{
+ CommandDisposition, CommandOutput, CommandView, DoctorCheckView, DoctorView,
+};
+use crate::runtime::config::{RuntimeConfig, SignerBackend};
+use crate::runtime::logging::LoggingState;
+use crate::runtime::signer::resolve_signer_status;
+use radroots_identity::IdentityError;
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
+enum DoctorSeverity {
+ Ok,
+ Warn,
+ ExternalFail,
+ InternalFail,
+}
+
+impl DoctorSeverity {
+ fn status(self) -> &'static str {
+ match self {
+ Self::Ok => "ok",
+ Self::Warn => "warn",
+ Self::ExternalFail | Self::InternalFail => "fail",
+ }
+ }
+
+ fn command_disposition(self) -> CommandDisposition {
+ match self {
+ Self::Ok => CommandDisposition::Success,
+ Self::Warn => CommandDisposition::Unconfigured,
+ Self::ExternalFail => CommandDisposition::ExternalUnavailable,
+ Self::InternalFail => CommandDisposition::InternalError,
+ }
+ }
+}
+
+struct EvaluatedCheck {
+ severity: DoctorSeverity,
+ view: DoctorCheckView,
+ action: Option<&'static str>,
+}
+
+pub fn report(config: &RuntimeConfig, logging: &LoggingState) -> CommandOutput {
+ let mut checks = Vec::new();
+ checks.push(config_check(config));
+ checks.push(account_check(config));
+
+ let signer = resolve_signer_status(config);
+ checks.push(signer_check(&signer));
+
+ if matches!(config.signer.backend, SignerBackend::Myc) {
+ if let Some(myc) = signer.myc.as_ref() {
+ checks.push(myc_check(myc));
+ }
+ }
+
+ checks.push(logging_check(config, logging));
+
+ let severity = checks
+ .iter()
+ .map(|check| check.severity)
+ .max()
+ .unwrap_or(DoctorSeverity::Ok);
+ let actions = collect_actions(&checks);
+ let view = DoctorView {
+ ok: severity == DoctorSeverity::Ok,
+ state: severity.status().to_owned(),
+ checks: checks.into_iter().map(|check| check.view).collect(),
+ source: match config.signer.backend {
+ SignerBackend::Local => "local diagnostics".to_owned(),
+ SignerBackend::Myc => "local diagnostics + myc status command".to_owned(),
+ },
+ actions,
+ };
+
+ match severity.command_disposition() {
+ CommandDisposition::Success => CommandOutput::success(CommandView::Doctor(view)),
+ CommandDisposition::Unconfigured => CommandOutput::unconfigured(CommandView::Doctor(view)),
+ CommandDisposition::ExternalUnavailable => {
+ CommandOutput::external_unavailable(CommandView::Doctor(view))
+ }
+ CommandDisposition::InternalError => {
+ CommandOutput::internal_error(CommandView::Doctor(view))
+ }
+ }
+}
+
+fn config_check(config: &RuntimeConfig) -> EvaluatedCheck {
+ let detail = match (
+ config.paths.user_config_path.exists(),
+ config.paths.workspace_config_path.exists(),
+ ) {
+ (false, false) => "defaults active".to_owned(),
+ (true, false) => "user config root present".to_owned(),
+ (false, true) => "workspace config root present".to_owned(),
+ (true, true) => "user and workspace config roots present".to_owned(),
+ };
+
+ EvaluatedCheck {
+ severity: DoctorSeverity::Ok,
+ view: DoctorCheckView {
+ name: "config".to_owned(),
+ status: "ok".to_owned(),
+ detail,
+ },
+ action: None,
+ }
+}
+
+fn account_check(config: &RuntimeConfig) -> EvaluatedCheck {
+ match crate::runtime::identity::load_identity(&config.identity) {
+ Ok(identity) => EvaluatedCheck {
+ severity: DoctorSeverity::Ok,
+ view: DoctorCheckView {
+ name: "account".to_owned(),
+ status: "ok".to_owned(),
+ detail: format!("{} loaded", identity.public_identity.id),
+ },
+ action: None,
+ },
+ Err(crate::runtime::RuntimeError::Identity(IdentityError::NotFound(path))) => {
+ EvaluatedCheck {
+ severity: DoctorSeverity::Warn,
+ view: DoctorCheckView {
+ name: "account".to_owned(),
+ status: "warn".to_owned(),
+ detail: format!("no local account at {}", path.display()),
+ },
+ action: Some("radroots account new"),
+ }
+ }
+ Err(error) => EvaluatedCheck {
+ severity: DoctorSeverity::InternalFail,
+ view: DoctorCheckView {
+ name: "account".to_owned(),
+ status: "fail".to_owned(),
+ detail: error.to_string(),
+ },
+ action: Some("radroots account whoami --json"),
+ },
+ }
+}
+
+fn signer_check(signer: &crate::domain::runtime::SignerStatusView) -> EvaluatedCheck {
+ let (severity, detail, action) = match signer.state.as_str() {
+ "ready" => (
+ DoctorSeverity::Ok,
+ format!("{} ready", signer.backend),
+ None,
+ ),
+ "unconfigured" => (
+ DoctorSeverity::Warn,
+ signer
+ .reason
+ .clone()
+ .unwrap_or_else(|| format!("{} signer is not configured", signer.backend)),
+ Some("radroots signer status"),
+ ),
+ "degraded" | "unavailable" => (
+ DoctorSeverity::ExternalFail,
+ signer
+ .reason
+ .clone()
+ .unwrap_or_else(|| format!("{} signer is unavailable", signer.backend)),
+ Some(if signer.backend == "myc" {
+ "radroots myc status"
+ } else {
+ "radroots signer status"
+ }),
+ ),
+ _ => (
+ DoctorSeverity::InternalFail,
+ signer
+ .reason
+ .clone()
+ .unwrap_or_else(|| format!("{} signer reported an internal error", signer.backend)),
+ Some("radroots signer status --json"),
+ ),
+ };
+
+ EvaluatedCheck {
+ severity,
+ view: DoctorCheckView {
+ name: "signer".to_owned(),
+ status: severity.status().to_owned(),
+ detail,
+ },
+ action,
+ }
+}
+
+fn myc_check(myc: &crate::domain::runtime::MycStatusView) -> EvaluatedCheck {
+ let (severity, detail, action) = match myc.state.as_str() {
+ "ready" => (
+ DoctorSeverity::Ok,
+ myc.service_status
+ .clone()
+ .unwrap_or_else(|| "service ready".to_owned()),
+ None,
+ ),
+ "unconfigured" => (
+ DoctorSeverity::Warn,
+ myc.reason
+ .clone()
+ .unwrap_or_else(|| "myc is not configured".to_owned()),
+ Some("radroots myc status"),
+ ),
+ _ => (
+ DoctorSeverity::ExternalFail,
+ myc.reason
+ .clone()
+ .unwrap_or_else(|| "myc is unavailable".to_owned()),
+ Some("radroots myc status"),
+ ),
+ };
+
+ EvaluatedCheck {
+ severity,
+ view: DoctorCheckView {
+ name: "myc".to_owned(),
+ status: severity.status().to_owned(),
+ detail,
+ },
+ action,
+ }
+}
+
+fn logging_check(config: &RuntimeConfig, logging: &LoggingState) -> EvaluatedCheck {
+ let detail = match (config.logging.stdout, logging.current_file.as_ref()) {
+ (true, Some(path)) => format!("stdout + file {}", path.display()),
+ (true, None) => "stdout only".to_owned(),
+ (false, Some(path)) => format!("file {}", path.display()),
+ (false, None) => "stdout off · no file sink".to_owned(),
+ };
+
+ EvaluatedCheck {
+ severity: DoctorSeverity::Ok,
+ view: DoctorCheckView {
+ name: "logging".to_owned(),
+ status: "ok".to_owned(),
+ detail,
+ },
+ action: None,
+ }
+}
+
+fn collect_actions(checks: &[EvaluatedCheck]) -> Vec<String> {
+ let mut actions = Vec::new();
+ for action in checks.iter().filter_map(|check| check.action) {
+ if !actions.iter().any(|existing| existing == action) {
+ actions.push(action.to_owned());
+ }
+ }
+ actions
+}
diff --git a/src/commands/mod.rs b/src/commands/mod.rs
@@ -1,3 +1,4 @@
+pub mod doctor;
pub mod identity;
pub mod myc;
pub mod runtime;
@@ -37,7 +38,7 @@ pub fn dispatch(
Command::Signer(signer) => match &signer.command {
SignerCommand::Status => Ok(signer::status(config)),
},
- Command::Doctor => unimplemented_command("doctor"),
+ Command::Doctor => Ok(doctor::report(config, logging)),
Command::Find(_) => unimplemented_command("find"),
Command::Job(job) => match &job.command {
JobCommand::Ls => unimplemented_command("job ls"),
diff --git a/src/commands/runtime.rs b/src/commands/runtime.rs
@@ -1,18 +1,23 @@
use crate::domain::runtime::{
- AccountRuntimeView, ConfigShowView, LoggingRuntimeView, MycRuntimeView, OutputRuntimeView,
- PathsRuntimeView, SignerRuntimeView,
+ AccountRuntimeView, ConfigFilesRuntimeView, ConfigShowView, LoggingRuntimeView, MycRuntimeView,
+ OutputRuntimeView, PathsRuntimeView, SignerRuntimeView,
};
use crate::runtime::config::RuntimeConfig;
use crate::runtime::logging::LoggingState;
pub fn show(config: &RuntimeConfig, logging: &LoggingState) -> ConfigShowView {
ConfigShowView {
+ source: "local runtime state".to_owned(),
output: OutputRuntimeView {
format: config.output.format.as_str().to_owned(),
verbosity: config.output.verbosity.as_str().to_owned(),
color: config.output.color,
dry_run: config.output.dry_run,
},
+ config_files: ConfigFilesRuntimeView {
+ user_present: config.paths.user_config_path.exists(),
+ workspace_present: config.paths.workspace_config_path.exists(),
+ },
paths: PathsRuntimeView {
user_config_path: config.paths.user_config_path.display().to_string(),
workspace_config_path: config.paths.workspace_config_path.display().to_string(),
diff --git a/src/domain/runtime.rs b/src/domain/runtime.rs
@@ -70,13 +70,16 @@ pub enum CommandView {
AccountNew(AccountNewView),
AccountWhoami(AccountWhoamiView),
ConfigShow(ConfigShowView),
+ Doctor(DoctorView),
MycStatus(MycStatusView),
SignerStatus(SignerStatusView),
}
#[derive(Debug, Clone, Serialize)]
pub struct ConfigShowView {
+ pub source: String,
pub output: OutputRuntimeView,
+ pub config_files: ConfigFilesRuntimeView,
pub paths: PathsRuntimeView,
pub logging: LoggingRuntimeView,
pub account: AccountRuntimeView,
@@ -93,6 +96,12 @@ pub struct OutputRuntimeView {
}
#[derive(Debug, Clone, Serialize)]
+pub struct ConfigFilesRuntimeView {
+ pub user_present: bool,
+ pub workspace_present: bool,
+}
+
+#[derive(Debug, Clone, Serialize)]
pub struct LoggingRuntimeView {
pub initialized: bool,
pub filter: String,
@@ -124,6 +133,23 @@ pub struct MycRuntimeView {
}
#[derive(Debug, Clone, Serialize)]
+pub struct DoctorView {
+ pub ok: bool,
+ pub state: String,
+ pub checks: Vec<DoctorCheckView>,
+ pub source: String,
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub actions: Vec<String>,
+}
+
+#[derive(Debug, Clone, Serialize)]
+pub struct DoctorCheckView {
+ pub name: String,
+ pub status: String,
+ pub detail: String,
+}
+
+#[derive(Debug, Clone, Serialize)]
pub struct IdentityPublicView {
pub id: String,
pub public_key_hex: String,
diff --git a/src/render/mod.rs b/src/render/mod.rs
@@ -1,9 +1,11 @@
use std::io::{self, Write};
-use crate::domain::runtime::{CommandOutput, CommandView};
+use crate::domain::runtime::{CommandOutput, CommandView, DoctorCheckView, DoctorView};
use crate::runtime::RuntimeError;
use crate::runtime::config::{OutputConfig, OutputFormat};
+const THIN_RULE: &str = "────────────────────────────────────────────────────";
+
pub fn render_output(output: &CommandOutput, config: &OutputConfig) -> Result<(), RuntimeError> {
match config.format {
OutputFormat::Human => render_human(output),
@@ -60,40 +62,10 @@ fn render_human_to(stdout: &mut dyn Write, output: &CommandOutput) -> Result<(),
render_myc_status(stdout, view)?;
}
CommandView::ConfigShow(view) => {
- writeln!(stdout, "config")?;
- writeln!(stdout, "output")?;
- writeln!(stdout, " format: {}", view.output.format)?;
- writeln!(stdout, " verbosity: {}", view.output.verbosity)?;
- writeln!(stdout, " color: {}", yes_no(view.output.color))?;
- writeln!(stdout, " dry run: {}", yes_no(view.output.dry_run))?;
- writeln!(stdout, "paths")?;
- writeln!(stdout, " user config: {}", view.paths.user_config_path)?;
- writeln!(
- stdout,
- " workspace config: {}",
- view.paths.workspace_config_path
- )?;
- writeln!(stdout, " user state root: {}", view.paths.user_state_root)?;
- writeln!(stdout, "logging")?;
- writeln!(
- stdout,
- " initialized: {}",
- yes_no(view.logging.initialized)
- )?;
- writeln!(stdout, " filter: {}", view.logging.filter)?;
- writeln!(stdout, " stdout: {}", yes_no(view.logging.stdout))?;
- if let Some(directory) = &view.logging.directory {
- writeln!(stdout, " directory: {directory}")?;
- }
- if let Some(current_file) = &view.logging.current_file {
- writeln!(stdout, " current file: {current_file}")?;
- }
- writeln!(stdout, "account")?;
- writeln!(stdout, " identity path: {}", view.account.identity_path)?;
- writeln!(stdout, "signer")?;
- writeln!(stdout, " backend: {}", view.signer.backend)?;
- writeln!(stdout, "myc")?;
- writeln!(stdout, " executable: {}", view.myc.executable)?;
+ render_config_show(stdout, view)?;
+ }
+ CommandView::Doctor(view) => {
+ render_doctor(stdout, view)?;
}
CommandView::SignerStatus(view) => {
writeln!(stdout, "signer")?;
@@ -136,6 +108,10 @@ fn render_json_to(stdout: &mut dyn Write, output: &CommandOutput) -> Result<(),
serde_json::to_writer_pretty(&mut *stdout, view)?;
writeln!(stdout)?;
}
+ CommandView::Doctor(view) => {
+ serde_json::to_writer_pretty(&mut *stdout, view)?;
+ writeln!(stdout)?;
+ }
CommandView::SignerStatus(view) => {
serde_json::to_writer_pretty(&mut *stdout, view)?;
writeln!(stdout)?;
@@ -160,6 +136,126 @@ fn yes_no(value: bool) -> &'static str {
if value { "yes" } else { "no" }
}
+fn present_absent(value: bool) -> &'static str {
+ if value { "present" } else { "absent" }
+}
+
+fn render_config_show(
+ stdout: &mut dyn Write,
+ view: &crate::domain::runtime::ConfigShowView,
+) -> Result<(), RuntimeError> {
+ write_context(stdout, "config · effective")?;
+ render_pairs(
+ stdout,
+ "output",
+ &[
+ ("format", view.output.format.as_str()),
+ ("verbosity", view.output.verbosity.as_str()),
+ ("color", yes_no(view.output.color)),
+ ("dry run", yes_no(view.output.dry_run)),
+ ],
+ )?;
+ let user_config = format!(
+ "{} · {}",
+ present_absent(view.config_files.user_present),
+ view.paths.user_config_path
+ );
+ let workspace_config = format!(
+ "{} · {}",
+ present_absent(view.config_files.workspace_present),
+ view.paths.workspace_config_path
+ );
+ render_pairs(
+ stdout,
+ "config roots",
+ &[
+ ("user config", user_config.as_str()),
+ ("workspace config", workspace_config.as_str()),
+ ("user state root", view.paths.user_state_root.as_str()),
+ ],
+ )?;
+
+ let mut logging_rows = vec![
+ ("filter", view.logging.filter.as_str()),
+ ("stdout", yes_no(view.logging.stdout)),
+ ];
+ if let Some(directory) = &view.logging.directory {
+ logging_rows.push(("directory", directory.as_str()));
+ }
+ if let Some(current_file) = &view.logging.current_file {
+ logging_rows.push(("file", current_file.as_str()));
+ }
+ render_pairs(stdout, "logging", logging_rows.as_slice())?;
+ render_pairs(
+ stdout,
+ "account",
+ &[("identity path", view.account.identity_path.as_str())],
+ )?;
+ render_pairs(
+ stdout,
+ "signer",
+ &[("backend", view.signer.backend.as_str())],
+ )?;
+ render_pairs(
+ stdout,
+ "myc",
+ &[("executable", view.myc.executable.as_str())],
+ )?;
+ writeln!(stdout, "source: {}", view.source)?;
+ Ok(())
+}
+
+fn render_doctor(stdout: &mut dyn Write, view: &DoctorView) -> Result<(), RuntimeError> {
+ write_context(stdout, "system · checks")?;
+ let table = Table {
+ headers: &["check", "status", "detail"],
+ rows: view.checks.iter().map(doctor_row).collect(),
+ };
+ render_table(stdout, &table)?;
+ if !view.actions.is_empty() {
+ writeln!(stdout)?;
+ writeln!(stdout, "actions")?;
+ for action in &view.actions {
+ writeln!(stdout, " › {action}")?;
+ }
+ }
+ writeln!(stdout)?;
+ writeln!(stdout, "source: {}", view.source)?;
+ Ok(())
+}
+
+fn doctor_row(check: &DoctorCheckView) -> Vec<String> {
+ vec![
+ check.name.clone(),
+ check.status.clone(),
+ check.detail.clone(),
+ ]
+}
+
+fn write_context(stdout: &mut dyn Write, line: &str) -> Result<(), RuntimeError> {
+ writeln!(stdout, "{line}")?;
+ writeln!(stdout, "{THIN_RULE}")?;
+ Ok(())
+}
+
+fn render_pairs(
+ stdout: &mut dyn Write,
+ heading: &str,
+ rows: &[(&str, &str)],
+) -> Result<(), RuntimeError> {
+ writeln!(stdout, "{heading}")?;
+ let label_width = rows
+ .iter()
+ .map(|(label, _)| label.len())
+ .max()
+ .unwrap_or_default();
+ for (label, value) in rows {
+ writeln!(stdout, " {label:label_width$} {value}")?;
+ }
+ writeln!(stdout)?;
+ Ok(())
+}
+
fn render_local_signer(
stdout: &mut dyn Write,
heading: &str,
@@ -280,6 +376,7 @@ fn human_command_name(view: &CommandView) -> &'static str {
CommandView::AccountNew(_) => "account new",
CommandView::AccountWhoami(_) => "account whoami",
CommandView::ConfigShow(_) => "config show",
+ CommandView::Doctor(_) => "doctor",
CommandView::MycStatus(_) => "myc status",
CommandView::SignerStatus(_) => "signer status",
}
@@ -289,7 +386,9 @@ fn human_command_name(view: &CommandView) -> &'static str {
mod tests {
use super::{Table, render_human_to, render_ndjson_to, render_table};
use crate::commands::runtime;
- use crate::domain::runtime::{CommandOutput, CommandView, MycStatusView};
+ use crate::domain::runtime::{
+ CommandOutput, CommandView, DoctorCheckView, DoctorView, MycStatusView,
+ };
use crate::runtime::config::{
IdentityConfig, LoggingConfig, MycConfig, OutputConfig, OutputFormat, PathsConfig,
RuntimeConfig, SignerBackend, SignerConfig, Verbosity,
@@ -404,6 +503,37 @@ mod tests {
}
#[test]
+ fn human_render_doctor_uses_check_table_and_actions() {
+ let output = CommandOutput::unconfigured(CommandView::Doctor(DoctorView {
+ ok: false,
+ state: "warn".to_owned(),
+ checks: vec![
+ DoctorCheckView {
+ name: "config".to_owned(),
+ status: "ok".to_owned(),
+ detail: "defaults active".to_owned(),
+ },
+ DoctorCheckView {
+ name: "account".to_owned(),
+ status: "warn".to_owned(),
+ detail: "no local account at identity.json".to_owned(),
+ },
+ ],
+ source: "local diagnostics".to_owned(),
+ actions: vec!["radroots account new".to_owned()],
+ }));
+ let mut buffer = Vec::new();
+ render_human_to(&mut buffer, &output).expect("render human");
+ let rendered = String::from_utf8(buffer).expect("utf8");
+ assert!(rendered.contains("system · checks"));
+ assert!(rendered.contains("check"));
+ assert!(rendered.contains("account warn"));
+ assert!(rendered.contains("actions"));
+ assert!(rendered.contains("› radroots account new"));
+ assert!(rendered.contains("source: local diagnostics"));
+ }
+
+ #[test]
fn table_renderer_aligns_columns() {
let table = Table {
headers: &["item", "status"],
diff --git a/tests/doctor.rs b/tests/doctor.rs
@@ -0,0 +1,111 @@
+use std::fs;
+use std::path::Path;
+use std::process::Command;
+
+use assert_cmd::prelude::*;
+use serde_json::Value;
+use tempfile::tempdir;
+
+fn doctor_command_in(workdir: &Path) -> Command {
+ let mut command = Command::cargo_bin("radroots").expect("binary");
+ command.current_dir(workdir);
+ command.env("HOME", workdir.join("home"));
+ for key in [
+ "RADROOTS_ENV_FILE",
+ "RADROOTS_OUTPUT",
+ "RADROOTS_CLI_LOGGING_FILTER",
+ "RADROOTS_CLI_LOGGING_OUTPUT_DIR",
+ "RADROOTS_CLI_LOGGING_STDOUT",
+ "RADROOTS_LOG_FILTER",
+ "RADROOTS_LOG_DIR",
+ "RADROOTS_LOG_STDOUT",
+ "RADROOTS_IDENTITY_PATH",
+ "RADROOTS_SIGNER_BACKEND",
+ "RADROOTS_MYC_EXECUTABLE",
+ ] {
+ command.env_remove(key);
+ }
+ command
+}
+
+#[test]
+fn doctor_reports_unconfigured_local_bootstrap_state() {
+ let dir = tempdir().expect("tempdir");
+ let output = doctor_command_in(dir.path())
+ .args(["--json", "doctor"])
+ .output()
+ .expect("run doctor");
+
+ assert_eq!(output.status.code(), Some(3));
+ let stdout = String::from_utf8(output.stdout).expect("utf8 stdout");
+ let json: Value = serde_json::from_str(stdout.as_str()).expect("json output");
+ assert_eq!(json["ok"], false);
+ assert_eq!(json["state"], "warn");
+ assert_eq!(json["checks"][0]["name"], "config");
+ assert_eq!(json["checks"][0]["status"], "ok");
+ assert_eq!(json["checks"][1]["name"], "account");
+ assert_eq!(json["checks"][1]["status"], "warn");
+ assert_eq!(json["checks"][2]["name"], "signer");
+ assert_eq!(json["checks"][2]["status"], "warn");
+ assert_eq!(json["source"], "local diagnostics");
+ assert_eq!(json["actions"][0], "radroots account new");
+}
+
+#[test]
+fn doctor_reports_ready_local_bootstrap_state() {
+ let dir = tempdir().expect("tempdir");
+ let identity_path = dir.path().join("identity.json");
+ let init = doctor_command_in(dir.path())
+ .args([
+ "--json",
+ "--identity-path",
+ identity_path.to_str().expect("identity path"),
+ "account",
+ "new",
+ ])
+ .output()
+ .expect("run account new");
+ assert!(init.status.success());
+
+ let output = doctor_command_in(dir.path())
+ .args([
+ "--json",
+ "--identity-path",
+ identity_path.to_str().expect("identity path"),
+ "doctor",
+ ])
+ .output()
+ .expect("run doctor");
+
+ assert!(output.status.success());
+ let stdout = String::from_utf8(output.stdout).expect("utf8 stdout");
+ let json: Value = serde_json::from_str(stdout.as_str()).expect("json output");
+ assert_eq!(json["ok"], true);
+ assert_eq!(json["state"], "ok");
+ assert_eq!(json["checks"][1]["name"], "account");
+ assert_eq!(json["checks"][1]["status"], "ok");
+ assert_eq!(json["checks"][2]["name"], "signer");
+ assert_eq!(json["checks"][2]["status"], "ok");
+ assert_eq!(json["actions"], Value::Null);
+}
+
+#[test]
+fn doctor_reports_external_failure_for_missing_myc() {
+ let dir = tempdir().expect("tempdir");
+ fs::write(dir.path().join(".env"), "RADROOTS_SIGNER_BACKEND=myc\n").expect("write env file");
+
+ let output = doctor_command_in(dir.path())
+ .args(["--json", "--myc-executable", "missing-myc", "doctor"])
+ .output()
+ .expect("run doctor");
+
+ assert_eq!(output.status.code(), Some(4));
+ let stdout = String::from_utf8(output.stdout).expect("utf8 stdout");
+ let json: Value = serde_json::from_str(stdout.as_str()).expect("json output");
+ assert_eq!(json["state"], "fail");
+ assert_eq!(json["checks"][2]["name"], "signer");
+ assert_eq!(json["checks"][2]["status"], "fail");
+ assert_eq!(json["checks"][3]["name"], "myc");
+ assert_eq!(json["checks"][3]["status"], "fail");
+ assert_eq!(json["source"], "local diagnostics + myc status command");
+}
diff --git a/tests/runtime_show.rs b/tests/runtime_show.rs
@@ -40,6 +40,7 @@ fn config_show_json_reports_default_bootstrap_state() {
assert!(output.status.success());
let stdout = String::from_utf8(output.stdout).expect("utf8 stdout");
let json: Value = serde_json::from_str(stdout.as_str()).expect("json output");
+ assert_eq!(json["source"], "local runtime state");
assert_eq!(json["output"]["format"], "json");
assert_eq!(json["output"]["verbosity"], "normal");
assert_eq!(json["output"]["color"], true);
@@ -70,6 +71,8 @@ fn config_show_json_reports_default_bootstrap_state() {
assert_eq!(json["logging"]["initialized"], true);
assert_eq!(json["logging"]["stdout"], false);
assert_eq!(json["logging"]["directory"], Value::Null);
+ assert_eq!(json["config_files"]["user_present"], false);
+ assert_eq!(json["config_files"]["workspace_present"], false);
assert_eq!(json["account"]["identity_path"], "identity.json");
assert_eq!(json["signer"]["backend"], "local");
assert_eq!(json["myc"]["executable"], "myc");