use std::path::{Path, PathBuf};
use crate::error::Result;
use crate::json::escape;
use crate::manifest::CompilerIdentity;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ToolVersionStatus {
Supported { version: String },
Unsupported {
first_line: Option<String>,
exit_status: Option<i32>,
},
CommandFailed {
first_line: Option<String>,
exit_status: Option<i32>,
},
NotRun { reason: String },
}
impl ToolVersionStatus {
fn to_json(&self) -> String {
let optline = |o: &Option<String>| {
o.as_ref()
.map(|s| escape(s))
.unwrap_or_else(|| "null".into())
};
let optcode = |o: &Option<i32>| o.map(|c| c.to_string()).unwrap_or_else(|| "null".into());
match self {
ToolVersionStatus::Supported { version } => {
format!(
"{{ \"status\": \"supported\", \"version\": {} }}",
escape(version)
)
}
ToolVersionStatus::Unsupported {
first_line,
exit_status,
} => format!(
"{{ \"status\": \"unsupported\", \"first_line\": {}, \"exit_status\": {} }}",
optline(first_line),
optcode(exit_status)
),
ToolVersionStatus::CommandFailed {
first_line,
exit_status,
} => format!(
"{{ \"status\": \"command_failed\", \"first_line\": {}, \"exit_status\": {} }}",
optline(first_line),
optcode(exit_status)
),
ToolVersionStatus::NotRun { reason } => {
format!(
"{{ \"status\": \"not_run\", \"reason\": {} }}",
escape(reason)
)
}
}
}
fn label(&self) -> String {
match self {
ToolVersionStatus::Supported { version } => version.clone(),
ToolVersionStatus::Unsupported { exit_status, .. } => {
format!(
"(--version unsupported, exit {})",
exit_status.unwrap_or(-1)
)
}
ToolVersionStatus::CommandFailed { .. } => "(--version failed)".into(),
ToolVersionStatus::NotRun { .. } => "(--version not run)".into(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum HashReadStatus {
Read { sha256: String },
Unreadable { reason: String },
NotAttempted { reason: String },
}
impl HashReadStatus {
fn to_json(&self) -> String {
match self {
HashReadStatus::Read { sha256 } => {
format!("{{ \"status\": \"read\", \"sha256\": {} }}", escape(sha256))
}
HashReadStatus::Unreadable { reason } => {
format!(
"{{ \"status\": \"unreadable\", \"reason\": {} }}",
escape(reason)
)
}
HashReadStatus::NotAttempted { reason } => {
format!(
"{{ \"status\": \"not_attempted\", \"reason\": {} }}",
escape(reason)
)
}
}
}
fn label(&self) -> String {
match self {
HashReadStatus::Read { sha256 } => {
sha256.chars().take(12).collect::<String>()
}
HashReadStatus::Unreadable { .. } => "(unreadable)".into(),
HashReadStatus::NotAttempted { .. } => "(hash not attempted)".into(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ToolStatus {
Present {
path: String,
version_status: ToolVersionStatus,
hash_status: HashReadStatus,
},
Absent,
}
impl ToolStatus {
fn to_json(&self) -> String {
match self {
ToolStatus::Present {
path,
version_status,
hash_status,
} => {
format!(
"{{ \"status\": \"present\", \"path\": {}, \"version_status\": {}, \"hash_status\": {} }}",
escape(path),
version_status.to_json(),
hash_status.to_json()
)
}
ToolStatus::Absent => "{ \"status\": \"absent\" }".to_string(),
}
}
fn label(&self) -> String {
match self {
ToolStatus::Present {
path,
version_status,
hash_status,
} => {
format!(
"present — {path} [{}] ({})",
version_status.label(),
hash_status.label()
)
}
ToolStatus::Absent => "absent".into(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TzdataStatus {
NotProbed,
NotFound(String),
Found {
path: String,
detected_version: Option<String>,
sha256: String,
},
}
#[derive(Debug, Clone)]
pub struct DoctorOptions {
pub reference_zic: String,
pub reference_zdump: String,
pub tzdata: Option<PathBuf>,
}
#[derive(Debug)]
pub struct DoctorReport {
pub reference_zic: ToolStatus,
pub reference_zdump: ToolStatus,
pub tzdata: TzdataStatus,
pub compiler: CompilerIdentity,
}
pub const SCHEMA: &str = "zic-rs-doctor-v2";
pub(crate) fn resolve(program: &str) -> Option<PathBuf> {
let p = Path::new(program);
if p.is_absolute() || p.components().count() > 1 {
return if p.is_file() {
Some(p.to_path_buf())
} else {
None
};
}
let path = std::env::var_os("PATH")?;
for dir in std::env::split_paths(&path) {
let cand = dir.join(program);
if cand.is_file() {
return Some(cand);
}
}
None
}
fn probe_tool(program: &str) -> ToolStatus {
let Some(path) = resolve(program) else {
return ToolStatus::Absent;
};
let hash_status = match std::fs::read(&path) {
Ok(b) => HashReadStatus::Read {
sha256: crate::hash::sha256_hex(&b),
},
Err(e) => HashReadStatus::Unreadable {
reason: e.to_string(),
},
};
let version_status = match std::process::Command::new(&path).arg("--version").output() {
Err(e) => ToolVersionStatus::NotRun {
reason: e.to_string(),
},
Ok(o) => {
let pick = if o.stdout.is_empty() {
&o.stderr
} else {
&o.stdout
};
let first_line = String::from_utf8_lossy(pick)
.lines()
.next()
.map(|l| l.trim().to_string())
.filter(|l| !l.is_empty());
match o.status.code() {
Some(0) if first_line.is_some() => ToolVersionStatus::Supported {
version: first_line.unwrap(),
},
Some(0) => ToolVersionStatus::CommandFailed {
first_line,
exit_status: Some(0),
},
Some(code) => ToolVersionStatus::Unsupported {
first_line,
exit_status: Some(code),
},
None => ToolVersionStatus::CommandFailed {
first_line,
exit_status: None,
},
}
}
};
ToolStatus::Present {
path: path.to_string_lossy().into_owned(),
version_status,
hash_status,
}
}
pub fn run_doctor(opts: &DoctorOptions) -> Result<DoctorReport> {
let tzdata = match &opts.tzdata {
None => TzdataStatus::NotProbed,
Some(p) => match std::fs::read(p) {
Ok(bytes) => TzdataStatus::Found {
path: p.to_string_lossy().into_owned(),
detected_version: crate::report::sniff_tzdb_version(&bytes),
sha256: crate::hash::sha256_hex(&bytes),
},
Err(e) => TzdataStatus::NotFound(format!("{}: {e}", p.display())),
},
};
Ok(DoctorReport {
reference_zic: probe_tool(&opts.reference_zic),
reference_zdump: probe_tool(&opts.reference_zdump),
tzdata,
compiler: CompilerIdentity::capture(),
})
}
impl DoctorReport {
pub fn to_json(&self) -> String {
let mut s = String::new();
s.push_str("{\n");
s.push_str(&format!(" \"schema\": {},\n", escape(SCHEMA)));
s.push_str(&crate::manifest::provenance_block_json());
s.push_str(
" \"non_claim\": \"doctor reads the host environment for DIAGNOSIS ONLY; it admits/validates \
nothing. Tool presence ≠ tool correctness ≠ an admitted reference (admission stays the \
versioned-archive + integrity-pin rule). The production compile path needs none of these tools.\",\n",
);
s.push_str(&format!(
" \"reference_zic\": {},\n",
self.reference_zic.to_json()
));
s.push_str(&format!(
" \"reference_zdump\": {},\n",
self.reference_zdump.to_json()
));
s.push_str(&format!(" \"tzdata\": {},\n", self.tzdata_json()));
let c = &self.compiler;
let opt = |o: Option<&str>| o.map(escape).unwrap_or_else(|| "null".into());
s.push_str(&format!(
" \"compiler_identity\": {{ \"zic_rs_version\": {}, \"rustc\": {}, \"target\": {}, \
\"profile\": {}, \"git_commit\": {} }}\n",
escape(c.zic_rs_version),
opt(c.rustc),
escape(&c.target),
escape(c.profile),
opt(c.git_commit),
));
s.push_str("}\n");
s
}
fn tzdata_json(&self) -> String {
match &self.tzdata {
TzdataStatus::NotProbed => "{ \"status\": \"not_probed\" }".into(),
TzdataStatus::NotFound(reason) => {
format!(
"{{ \"status\": \"not_found\", \"reason\": {} }}",
escape(reason)
)
}
TzdataStatus::Found {
path,
detected_version,
sha256,
} => {
let v = detected_version
.as_ref()
.map(|s| escape(s))
.unwrap_or_else(|| "null".into());
format!(
"{{ \"status\": \"found\", \"path\": {}, \"detected_version\": {}, \"sha256\": {} }}",
escape(path),
v,
escape(sha256)
)
}
}
}
pub fn to_text(&self) -> String {
let mut s = String::new();
s.push_str(
"zic-rs doctor (read-only environment probe — diagnosis only, admits nothing)\n",
);
s.push_str(&format!(
" reference zic : {}\n",
self.reference_zic.label()
));
s.push_str(&format!(
" reference zdump : {}\n",
self.reference_zdump.label()
));
let tz = match &self.tzdata {
TzdataStatus::NotProbed => "not probed (pass --tzdata <path>)".to_string(),
TzdataStatus::NotFound(r) => format!("not found ({r})"),
TzdataStatus::Found {
path,
detected_version,
..
} => format!(
"{path} (version {})",
detected_version.as_deref().unwrap_or("unknown")
),
};
s.push_str(&format!(" tzdata.zi : {tz}\n"));
s.push_str(&format!(
" zic-rs : {} ({} / {})\n",
self.compiler.zic_rs_version, self.compiler.target, self.compiler.profile
));
s
}
}