use std::path::Path;
use alp_core::{
BuildOs, BuildToolProbe, DebugServerKind, DebugTargetKind, DebuggerExtensionsState,
DoctorCheck, DoctorReport, DoctorStatus, DoctorSummary, ProjectContext, board_os_set,
build_doctor_report, build_readiness_report, collect_runtime_capabilities_from_commands,
create_debug_workspace_context, is_server_supported_for_target, parse_board_model,
parse_server_kind, parse_target_kind,
};
use super::CommandRun;
use crate::cli::{DoctorArgs, GlobalArgs};
use crate::envelope::{Envelope, Issue, Project};
use crate::exit::ExitCode;
use crate::style::{self, Theme};
use crate::util::{command_on_path, generated_at_iso, resolve_cli_project_context};
pub fn run(g: &GlobalArgs, args: &DoctorArgs) -> CommandRun {
let generated_at = generated_at_iso();
if args.build {
return run_build_readiness(g, &generated_at);
}
let context = resolve_context(g, &generated_at);
let resolved_project = Project {
root: context.workspace_root.clone(),
board_yaml: context.board_yaml_path.clone(),
};
let target = match parse_target_kind(args.target_kind.as_deref()) {
Ok(value) => value,
Err(message) => return internal_failure(g, &generated_at, message),
};
let server = match parse_server_kind(args.server.as_deref()) {
Ok(value) => value,
Err(message) => return internal_failure(g, &generated_at, message),
};
if !is_server_supported_for_target(target, server) {
return unsupported_server(g, &generated_at, target, server);
}
let runtime =
collect_runtime_capabilities_from_commands(&project_context(&context), command_on_path);
let mut report = build_doctor_report(&context, target, server, &runtime);
append_sdk_provenance(
&mut report.checks,
&mut report.summary,
context.sdk_root.as_deref(),
);
let exit = if report.summary.fail > 0 {
ExitCode::DoctorFailure
} else {
ExitCode::Success
};
let issues = checks_to_issues(&report.checks);
let text = if g.is_json() {
Vec::new()
} else {
format_doctor_text(g, &report)
};
let json = g
.is_json()
.then(|| Envelope::new("doctor", resolved_project, report, issues, exit.code()).to_json());
CommandRun { exit, text, json }
}
fn run_build_readiness(g: &GlobalArgs, generated_at: &str) -> CommandRun {
let context = resolve_cli_project_context(g);
let resolved_project = Project {
root: context.workspace_root.clone(),
board_yaml: context.board_yaml_path.clone(),
};
let os_set = read_board_model(&context)
.map(|board| board_os_set(&board))
.unwrap_or_else(|| vec![BuildOs::Zephyr, BuildOs::Yocto, BuildOs::Baremetal]);
let probe = BuildToolProbe {
west: command_on_path("west"),
cmake: command_on_path("cmake"),
ninja: command_on_path("ninja"),
bitbake: command_on_path("bitbake"),
zephyr_sdk: zephyr_sdk_detected(),
bmaptool: command_on_path("bmaptool"),
dd: command_on_path("dd"),
is_linux: cfg!(target_os = "linux"),
};
let mut report = build_readiness_report(generated_at.to_string(), os_set, &probe);
append_sdk_provenance(
&mut report.checks,
&mut report.summary,
context.sdk_root.as_deref(),
);
let exit = if report.summary.fail > 0 {
ExitCode::DoctorFailure
} else {
ExitCode::Success
};
let issues = checks_to_issues(&report.checks);
let text = if g.is_json() {
Vec::new()
} else {
format_build_text(g, &report)
};
let json = g
.is_json()
.then(|| Envelope::new("doctor", resolved_project, report, issues, exit.code()).to_json());
CommandRun { exit, text, json }
}
fn append_sdk_provenance(
checks: &mut Vec<DoctorCheck>,
summary: &mut DoctorSummary,
sdk_root: Option<&str>,
) {
let Some(root) = sdk_root else {
return;
};
let commit = git_short_commit(root);
let version = read_sdk_version(root);
let mut detail = match (&version, &commit) {
(Some(v), Some(c)) => format!("alp-sdk {v} @ {c}"),
(None, Some(c)) => format!("alp-sdk @ {c}"),
(Some(v), None) => format!("alp-sdk {v}"),
(None, None) => {
format!("alp-sdk at {root} (no git checkout / metadata/sdk_version.yaml)")
}
};
let (status, fix) = match git_behind_upstream(root) {
Some(n) if n > 0 => {
detail = format!("{detail} — {n} commit(s) behind upstream");
(
DoctorStatus::Warn,
Some(format!("Update the SDK checkout: git -C {root} pull")),
)
}
_ => (DoctorStatus::Pass, None),
};
match status {
DoctorStatus::Pass => summary.pass += 1,
DoctorStatus::Warn => summary.warn += 1,
DoctorStatus::Fail => summary.fail += 1,
}
checks.push(DoctorCheck {
name: "sdkProvenance".to_string(),
status,
detail,
fix,
});
}
fn git_short_commit(root: &str) -> Option<String> {
let output = std::process::Command::new("git")
.args(["-C", root, "rev-parse", "--short", "HEAD"])
.output()
.ok()?;
if !output.status.success() {
return None;
}
let commit = String::from_utf8(output.stdout).ok()?.trim().to_string();
(!commit.is_empty()).then_some(commit)
}
fn git_behind_upstream(root: &str) -> Option<u32> {
let output = std::process::Command::new("git")
.args(["-C", root, "rev-list", "--count", "HEAD..@{upstream}"])
.output()
.ok()?;
if !output.status.success() {
return None;
}
String::from_utf8(output.stdout).ok()?.trim().parse().ok()
}
fn read_sdk_version(root: &str) -> Option<String> {
let path = Path::new(root).join("metadata").join("sdk_version.yaml");
let text = std::fs::read_to_string(path).ok()?;
let mut bare: Option<String> = None;
for raw in text.lines() {
let line = raw.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
if let Some(rest) = line.strip_prefix("version:") {
let value = rest.trim().trim_matches('"').trim_matches('\'');
if !value.is_empty() {
return Some(value.to_string());
}
} else if bare.is_none() && !line.contains(':') {
bare = Some(line.trim_matches('"').trim_matches('\'').to_string());
}
}
bare
}
fn read_board_model(context: &ProjectContext) -> Option<alp_core::BoardModel> {
let path = context.board_yaml_path.as_deref()?;
let source = std::fs::read_to_string(path).ok()?;
parse_board_model(&source).ok()
}
fn zephyr_sdk_detected() -> bool {
if std::env::var_os("ZEPHYR_SDK_INSTALL_DIR").is_some() {
return true;
}
let mut roots: Vec<std::path::PathBuf> = vec![std::path::PathBuf::from("/opt")];
if let Some(home) = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")) {
roots.push(std::path::PathBuf::from(home));
}
roots.iter().any(|root| {
std::fs::read_dir(root)
.map(|entries| {
entries.flatten().any(|entry| {
entry
.file_name()
.to_string_lossy()
.starts_with("zephyr-sdk")
})
})
.unwrap_or(false)
})
}
fn format_build_text(g: &GlobalArgs, report: &alp_core::BuildReadinessReport) -> Vec<String> {
let subtitle = report
.os_set
.iter()
.map(|os| {
serde_json::to_value(os)
.ok()
.and_then(|v| v.as_str().map(str::to_string))
.unwrap_or_default()
})
.collect::<Vec<_>>()
.join(" · ");
style::render_report(
g,
"alp doctor --build",
&subtitle,
&report.checks,
&report.summary,
&report.next_steps,
)
}
fn resolve_context(g: &GlobalArgs, generated_at: &str) -> alp_core::DebugWorkspaceContext {
let project = resolve_cli_project_context(g);
let extensions = DebuggerExtensionsState {
cortex_debug: true,
cpp_tools: true,
code_lldb: true,
};
create_debug_workspace_context(
&project,
generated_at.to_string(),
|path| Path::new(path).exists(),
extensions,
)
}
fn project_context(context: &alp_core::DebugWorkspaceContext) -> ProjectContext {
ProjectContext {
workspace_root: context.workspace_root.clone(),
sdk_root: context.sdk_root.clone(),
board_yaml_path: context.board_yaml_path.clone(),
west_cwd: None,
python_binary: context.python_binary.clone(),
}
}
fn checks_to_issues(checks: &[DoctorCheck]) -> Vec<Issue> {
checks
.iter()
.filter(|c| c.status != DoctorStatus::Pass)
.map(|c| Issue {
code: format!("doctor.{}", c.name),
severity: if c.status == DoctorStatus::Fail {
"error".to_string()
} else {
"warning".to_string()
},
message: c.detail.clone(),
})
.collect()
}
fn format_doctor_text(g: &GlobalArgs, report: &DoctorReport) -> Vec<String> {
let subtitle = format!(
"{} · {}",
report.target_kind.as_str(),
report.server.as_str()
);
style::render_report(
g,
"alp doctor",
&subtitle,
&report.checks,
&report.summary,
&report.next_steps,
)
}
fn empty_report(
generated_at: &str,
target: DebugTargetKind,
server: DebugServerKind,
next_steps: Vec<String>,
) -> DoctorReport {
DoctorReport {
generated_at: generated_at.to_string(),
target_kind: target,
server,
summary: DoctorSummary {
pass: 0,
warn: 0,
fail: 1,
},
checks: Vec::new(),
next_steps,
}
}
fn unsupported_server(
g: &GlobalArgs,
generated_at: &str,
target: DebugTargetKind,
server: DebugServerKind,
) -> CommandRun {
let issues = vec![Issue {
code: "doctor.server-compatibility".to_string(),
severity: "error".to_string(),
message: format!(
"Server '{}' is not supported for '{}'.",
server.as_str(),
target.as_str()
),
}];
let data = empty_report(
generated_at,
target,
server,
vec!["Choose a supported server for the selected target-kind.".to_string()],
);
let text = if g.is_json() {
Vec::new()
} else {
Theme::from_args(g).error_lines(&format!(
"Server '{}' is not supported for target '{}'.",
server.as_str(),
target.as_str()
))
};
let json = g.is_json().then(|| {
Envelope::new(
"doctor",
null_project(),
data,
issues,
ExitCode::DoctorFailure.code(),
)
.to_json()
});
CommandRun {
exit: ExitCode::DoctorFailure,
text,
json,
}
}
fn internal_failure(g: &GlobalArgs, generated_at: &str, message: String) -> CommandRun {
let issues = vec![Issue {
code: "doctor.internal-failure".to_string(),
severity: "error".to_string(),
message: message.clone(),
}];
let data = empty_report(
generated_at,
DebugTargetKind::NativeHost,
DebugServerKind::None,
Vec::new(),
);
let text = if g.is_json() {
Vec::new()
} else {
Theme::from_args(g).error_lines(&message)
};
let json = g.is_json().then(|| {
Envelope::new(
"doctor",
null_project(),
data,
issues,
ExitCode::InternalFailure.code(),
)
.to_json()
});
CommandRun {
exit: ExitCode::InternalFailure,
text,
json,
}
}
fn null_project() -> Project {
Project {
root: None,
board_yaml: None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn issues_skip_passing_checks() {
let checks = vec![
DoctorCheck {
name: "ok".to_string(),
status: DoctorStatus::Pass,
detail: "fine".to_string(),
fix: None,
},
DoctorCheck {
name: "warned".to_string(),
status: DoctorStatus::Warn,
detail: "careful".to_string(),
fix: Some("do x".to_string()),
},
DoctorCheck {
name: "broken".to_string(),
status: DoctorStatus::Fail,
detail: "nope".to_string(),
fix: Some("do y".to_string()),
},
];
let issues = checks_to_issues(&checks);
assert_eq!(issues.len(), 2);
assert_eq!(issues[0].code, "doctor.warned");
assert_eq!(issues[0].severity, "warning");
assert_eq!(issues[1].code, "doctor.broken");
assert_eq!(issues[1].severity, "error");
}
#[test]
fn unsupported_server_emits_doctor_failure_envelope() {
let g = GlobalArgs {
project: None,
board_yaml: None,
sdk_root: None,
target: None,
all: false,
format: crate::cli::Format::Json,
verbose: false,
quiet: false,
no_color: false,
non_interactive: false,
ci: false,
};
let run = unsupported_server(
&g,
"1970-01-01T00:00:00.000Z",
DebugTargetKind::NativeHost,
DebugServerKind::Jlink,
);
assert_eq!(run.exit, ExitCode::DoctorFailure);
let json = run.json.expect("json envelope");
assert!(json.contains("\"command\":\"doctor\""));
assert!(json.contains("\"exitCode\":4"));
assert!(json.contains("\"ok\":false"));
assert!(json.contains("\"root\":null"));
assert!(json.contains("doctor.server-compatibility"));
assert!(json.contains("\"checks\":[]"));
assert!(json.contains("Server 'jlink' is not supported for 'native-host'."));
assert!(json.contains("Choose a supported server for the selected target-kind."));
}
}