1use std::path::{Path, PathBuf};
20
21use crate::error::Result;
22use crate::json::escape;
23use crate::manifest::CompilerIdentity;
24
25#[derive(Debug, Clone, PartialEq, Eq)]
30pub enum ToolVersionStatus {
31 Supported { version: String },
33 Unsupported {
37 first_line: Option<String>,
38 exit_status: Option<i32>,
39 },
40 CommandFailed {
43 first_line: Option<String>,
44 exit_status: Option<i32>,
45 },
46 NotRun { reason: String },
48}
49
50impl ToolVersionStatus {
51 fn to_json(&self) -> String {
52 let optline = |o: &Option<String>| {
53 o.as_ref()
54 .map(|s| escape(s))
55 .unwrap_or_else(|| "null".into())
56 };
57 let optcode = |o: &Option<i32>| o.map(|c| c.to_string()).unwrap_or_else(|| "null".into());
58 match self {
59 ToolVersionStatus::Supported { version } => {
60 format!(
61 "{{ \"status\": \"supported\", \"version\": {} }}",
62 escape(version)
63 )
64 }
65 ToolVersionStatus::Unsupported {
66 first_line,
67 exit_status,
68 } => format!(
69 "{{ \"status\": \"unsupported\", \"first_line\": {}, \"exit_status\": {} }}",
70 optline(first_line),
71 optcode(exit_status)
72 ),
73 ToolVersionStatus::CommandFailed {
74 first_line,
75 exit_status,
76 } => format!(
77 "{{ \"status\": \"command_failed\", \"first_line\": {}, \"exit_status\": {} }}",
78 optline(first_line),
79 optcode(exit_status)
80 ),
81 ToolVersionStatus::NotRun { reason } => {
82 format!(
83 "{{ \"status\": \"not_run\", \"reason\": {} }}",
84 escape(reason)
85 )
86 }
87 }
88 }
89
90 fn label(&self) -> String {
92 match self {
93 ToolVersionStatus::Supported { version } => version.clone(),
94 ToolVersionStatus::Unsupported { exit_status, .. } => {
95 format!(
96 "(--version unsupported, exit {})",
97 exit_status.unwrap_or(-1)
98 )
99 }
100 ToolVersionStatus::CommandFailed { .. } => "(--version failed)".into(),
101 ToolVersionStatus::NotRun { .. } => "(--version not run)".into(),
102 }
103 }
104}
105
106#[derive(Debug, Clone, PartialEq, Eq)]
109pub enum HashReadStatus {
110 Read { sha256: String },
112 Unreadable { reason: String },
114 NotAttempted { reason: String },
117}
118
119impl HashReadStatus {
120 fn to_json(&self) -> String {
121 match self {
122 HashReadStatus::Read { sha256 } => {
123 format!("{{ \"status\": \"read\", \"sha256\": {} }}", escape(sha256))
124 }
125 HashReadStatus::Unreadable { reason } => {
126 format!(
127 "{{ \"status\": \"unreadable\", \"reason\": {} }}",
128 escape(reason)
129 )
130 }
131 HashReadStatus::NotAttempted { reason } => {
132 format!(
133 "{{ \"status\": \"not_attempted\", \"reason\": {} }}",
134 escape(reason)
135 )
136 }
137 }
138 }
139
140 fn label(&self) -> String {
141 match self {
142 HashReadStatus::Read { sha256 } => {
143 sha256.chars().take(12).collect::<String>()
145 }
146 HashReadStatus::Unreadable { .. } => "(unreadable)".into(),
147 HashReadStatus::NotAttempted { .. } => "(hash not attempted)".into(),
148 }
149 }
150}
151
152#[derive(Debug, Clone, PartialEq, Eq)]
154pub enum ToolStatus {
155 Present {
159 path: String,
160 version_status: ToolVersionStatus,
161 hash_status: HashReadStatus,
162 },
163 Absent,
165}
166
167impl ToolStatus {
168 fn to_json(&self) -> String {
169 match self {
170 ToolStatus::Present {
171 path,
172 version_status,
173 hash_status,
174 } => {
175 format!(
176 "{{ \"status\": \"present\", \"path\": {}, \"version_status\": {}, \"hash_status\": {} }}",
177 escape(path),
178 version_status.to_json(),
179 hash_status.to_json()
180 )
181 }
182 ToolStatus::Absent => "{ \"status\": \"absent\" }".to_string(),
183 }
184 }
185
186 fn label(&self) -> String {
187 match self {
188 ToolStatus::Present {
189 path,
190 version_status,
191 hash_status,
192 } => {
193 format!(
194 "present — {path} [{}] ({})",
195 version_status.label(),
196 hash_status.label()
197 )
198 }
199 ToolStatus::Absent => "absent".into(),
200 }
201 }
202}
203
204#[derive(Debug, Clone, PartialEq, Eq)]
206pub enum TzdataStatus {
207 NotProbed,
208 NotFound(String),
209 Found {
210 path: String,
211 detected_version: Option<String>,
212 sha256: String,
213 },
214}
215
216#[derive(Debug, Clone)]
218pub struct DoctorOptions {
219 pub reference_zic: String,
220 pub reference_zdump: String,
221 pub tzdata: Option<PathBuf>,
222}
223
224#[derive(Debug)]
226pub struct DoctorReport {
227 pub reference_zic: ToolStatus,
228 pub reference_zdump: ToolStatus,
229 pub tzdata: TzdataStatus,
230 pub compiler: CompilerIdentity,
231}
232
233pub const SCHEMA: &str = "zic-rs-doctor-v2";
240
241pub(crate) fn resolve(program: &str) -> Option<PathBuf> {
245 let p = Path::new(program);
246 if p.is_absolute() || p.components().count() > 1 {
247 return if p.is_file() {
248 Some(p.to_path_buf())
249 } else {
250 None
251 };
252 }
253 let path = std::env::var_os("PATH")?;
254 for dir in std::env::split_paths(&path) {
255 let cand = dir.join(program);
256 if cand.is_file() {
257 return Some(cand);
258 }
259 }
260 None
261}
262
263fn probe_tool(program: &str) -> ToolStatus {
268 let Some(path) = resolve(program) else {
269 return ToolStatus::Absent;
270 };
271 let hash_status = match std::fs::read(&path) {
272 Ok(b) => HashReadStatus::Read {
273 sha256: crate::hash::sha256_hex(&b),
274 },
275 Err(e) => HashReadStatus::Unreadable {
276 reason: e.to_string(),
277 },
278 };
279 let version_status = match std::process::Command::new(&path).arg("--version").output() {
280 Err(e) => ToolVersionStatus::NotRun {
281 reason: e.to_string(),
282 },
283 Ok(o) => {
284 let pick = if o.stdout.is_empty() {
285 &o.stderr
286 } else {
287 &o.stdout
288 };
289 let first_line = String::from_utf8_lossy(pick)
290 .lines()
291 .next()
292 .map(|l| l.trim().to_string())
293 .filter(|l| !l.is_empty());
294 match o.status.code() {
295 Some(0) if first_line.is_some() => ToolVersionStatus::Supported {
297 version: first_line.unwrap(),
298 },
299 Some(0) => ToolVersionStatus::CommandFailed {
301 first_line,
302 exit_status: Some(0),
303 },
304 Some(code) => ToolVersionStatus::Unsupported {
306 first_line,
307 exit_status: Some(code),
308 },
309 None => ToolVersionStatus::CommandFailed {
311 first_line,
312 exit_status: None,
313 },
314 }
315 }
316 };
317 ToolStatus::Present {
318 path: path.to_string_lossy().into_owned(),
319 version_status,
320 hash_status,
321 }
322}
323
324pub fn run_doctor(opts: &DoctorOptions) -> Result<DoctorReport> {
326 let tzdata = match &opts.tzdata {
327 None => TzdataStatus::NotProbed,
328 Some(p) => match std::fs::read(p) {
329 Ok(bytes) => TzdataStatus::Found {
330 path: p.to_string_lossy().into_owned(),
331 detected_version: crate::report::sniff_tzdb_version(&bytes),
332 sha256: crate::hash::sha256_hex(&bytes),
333 },
334 Err(e) => TzdataStatus::NotFound(format!("{}: {e}", p.display())),
335 },
336 };
337 Ok(DoctorReport {
338 reference_zic: probe_tool(&opts.reference_zic),
339 reference_zdump: probe_tool(&opts.reference_zdump),
340 tzdata,
341 compiler: CompilerIdentity::capture(),
342 })
343}
344
345impl DoctorReport {
346 pub fn to_json(&self) -> String {
348 let mut s = String::new();
349 s.push_str("{\n");
350 s.push_str(&format!(" \"schema\": {},\n", escape(SCHEMA)));
351 s.push_str(&crate::manifest::provenance_block_json());
352 s.push_str(
353 " \"non_claim\": \"doctor reads the host environment for DIAGNOSIS ONLY; it admits/validates \
354 nothing. Tool presence ≠ tool correctness ≠ an admitted reference (admission stays the \
355 versioned-archive + integrity-pin rule). The production compile path needs none of these tools.\",\n",
356 );
357 s.push_str(&format!(
358 " \"reference_zic\": {},\n",
359 self.reference_zic.to_json()
360 ));
361 s.push_str(&format!(
362 " \"reference_zdump\": {},\n",
363 self.reference_zdump.to_json()
364 ));
365 s.push_str(&format!(" \"tzdata\": {},\n", self.tzdata_json()));
366 let c = &self.compiler;
367 let opt = |o: Option<&str>| o.map(escape).unwrap_or_else(|| "null".into());
368 s.push_str(&format!(
369 " \"compiler_identity\": {{ \"zic_rs_version\": {}, \"rustc\": {}, \"target\": {}, \
370 \"profile\": {}, \"git_commit\": {} }}\n",
371 escape(c.zic_rs_version),
372 opt(c.rustc),
373 escape(&c.target),
374 escape(c.profile),
375 opt(c.git_commit),
376 ));
377 s.push_str("}\n");
378 s
379 }
380
381 fn tzdata_json(&self) -> String {
382 match &self.tzdata {
383 TzdataStatus::NotProbed => "{ \"status\": \"not_probed\" }".into(),
384 TzdataStatus::NotFound(reason) => {
385 format!(
386 "{{ \"status\": \"not_found\", \"reason\": {} }}",
387 escape(reason)
388 )
389 }
390 TzdataStatus::Found {
391 path,
392 detected_version,
393 sha256,
394 } => {
395 let v = detected_version
396 .as_ref()
397 .map(|s| escape(s))
398 .unwrap_or_else(|| "null".into());
399 format!(
400 "{{ \"status\": \"found\", \"path\": {}, \"detected_version\": {}, \"sha256\": {} }}",
401 escape(path),
402 v,
403 escape(sha256)
404 )
405 }
406 }
407 }
408
409 pub fn to_text(&self) -> String {
411 let mut s = String::new();
412 s.push_str(
413 "zic-rs doctor (read-only environment probe — diagnosis only, admits nothing)\n",
414 );
415 s.push_str(&format!(
416 " reference zic : {}\n",
417 self.reference_zic.label()
418 ));
419 s.push_str(&format!(
420 " reference zdump : {}\n",
421 self.reference_zdump.label()
422 ));
423 let tz = match &self.tzdata {
424 TzdataStatus::NotProbed => "not probed (pass --tzdata <path>)".to_string(),
425 TzdataStatus::NotFound(r) => format!("not found ({r})"),
426 TzdataStatus::Found {
427 path,
428 detected_version,
429 ..
430 } => format!(
431 "{path} (version {})",
432 detected_version.as_deref().unwrap_or("unknown")
433 ),
434 };
435 s.push_str(&format!(" tzdata.zi : {tz}\n"));
436 s.push_str(&format!(
437 " zic-rs : {} ({} / {})\n",
438 self.compiler.zic_rs_version, self.compiler.target, self.compiler.profile
439 ));
440 s
441 }
442}