1use crate::auth::{read_codex_auth_status, AuthCommandOptions};
2use crate::error::AppError;
3use crate::format::to_pretty_json;
4use crate::pricing::{
5 calculate_credit_cost, list_known_unpriced_models, list_model_pricing, normalize_model_name,
6 TokenUsage as PricingTokenUsage, CODEX_RATE_CARD_SOURCE,
7};
8use crate::stats::{read_usage_records_report, UsageRecordsReadOptions};
9use crate::storage::{resolve_storage_paths, StorageOptions};
10use chrono::{DateTime, Duration, SecondsFormat, Utc};
11use serde::Serialize;
12use serde_json::Value;
13use std::collections::BTreeMap;
14use std::fs;
15use std::io;
16use std::path::{Path, PathBuf};
17use std::process::Command;
18
19const MIN_NODE_VERSION: NodeVersion = NodeVersion {
20 major: 20,
21 minor: 12,
22 patch: 0,
23};
24const MIN_NODE_VERSION_LABEL: &str = ">=20.12.0";
25
26#[derive(Debug, Clone, Default, Eq, PartialEq)]
27pub struct DoctorOptions {
28 pub auth_file: Option<PathBuf>,
29 pub codex_home: Option<PathBuf>,
30 pub sessions_dir: Option<PathBuf>,
31 pub cycle_file: Option<PathBuf>,
32}
33
34#[derive(Debug, Clone, Serialize, Eq, PartialEq)]
35pub struct DoctorCheck {
36 pub name: String,
37 pub status: String,
38 pub message: String,
39 pub details: Vec<String>,
40}
41
42#[derive(Debug, Clone, Eq, PartialEq)]
43pub struct DoctorReport {
44 pub now: DateTime<Utc>,
45 pub codex_home: String,
46 pub auth_file: String,
47 pub sessions_dir: String,
48 pub helper_dir: String,
49 pub cycle_file: String,
50 pub checks: Vec<DoctorCheck>,
51}
52
53#[derive(Debug, Clone, Default)]
54struct RecentUsageSummary {
55 read_files: usize,
56 token_count_events: usize,
57 included_usage_events: usize,
58 unpriced_models: BTreeMap<String, RecentUnpricedModel>,
59}
60
61#[derive(Debug, Clone, Copy, Eq, Ord, PartialEq, PartialOrd)]
62struct NodeVersion {
63 major: u32,
64 minor: u32,
65 patch: u32,
66}
67
68#[derive(Debug, Clone, Default)]
69struct RecentUnpricedModel {
70 model: String,
71 calls: usize,
72 total_tokens: i64,
73 note: Option<String>,
74}
75
76#[derive(Serialize)]
77#[serde(rename_all = "camelCase")]
78struct DoctorJson<'a> {
79 now: String,
80 codex_home: &'a str,
81 auth_file: &'a str,
82 sessions_dir: &'a str,
83 helper_dir: &'a str,
84 cycle_file: &'a str,
85 checks: &'a [DoctorCheck],
86 summary: DoctorSummary,
87}
88
89#[derive(Serialize)]
90struct DoctorSummary {
91 errors: usize,
92 warnings: usize,
93}
94
95pub fn read_doctor_report(options: &DoctorOptions, now: DateTime<Utc>) -> DoctorReport {
96 let storage = resolve_storage_paths(&StorageOptions {
97 codex_home: options.codex_home.clone(),
98 auth_file: options.auth_file.clone(),
99 cycle_file: options.cycle_file.clone(),
100 sessions_dir: options.sessions_dir.clone(),
101 profile_store_dir: None,
102 account_history_file: None,
103 });
104
105 let checks = vec![
106 check_node_version(),
107 check_directory("Codex home", &storage.codex_home, false),
108 check_auth_file(&storage.auth_file, options, now),
109 check_directory("Sessions directory", &storage.sessions_dir, false),
110 check_helper_directory(&storage.helper_dir),
111 check_cycle_store(&storage.cycle_file),
112 check_recent_usage(&storage.sessions_dir, now),
113 check_pricing(),
114 ];
115
116 DoctorReport {
117 now,
118 codex_home: path_to_string(&storage.codex_home),
119 auth_file: path_to_string(&storage.auth_file),
120 sessions_dir: path_to_string(&storage.sessions_dir),
121 helper_dir: path_to_string(&storage.helper_dir),
122 cycle_file: path_to_string(&storage.cycle_file),
123 checks,
124 }
125}
126
127pub fn format_doctor_report(report: &DoctorReport, json: bool) -> Result<String, AppError> {
128 if json {
129 let value = DoctorJson {
130 now: format_iso(report.now),
131 codex_home: &report.codex_home,
132 auth_file: &report.auth_file,
133 sessions_dir: &report.sessions_dir,
134 helper_dir: &report.helper_dir,
135 cycle_file: &report.cycle_file,
136 checks: &report.checks,
137 summary: DoctorSummary {
138 errors: report
139 .checks
140 .iter()
141 .filter(|check| check.status == "error")
142 .count(),
143 warnings: report
144 .checks
145 .iter()
146 .filter(|check| check.status == "warn")
147 .count(),
148 },
149 };
150
151 return Ok(format!(
152 "{}\n",
153 to_pretty_json(&value).map_err(|error| AppError::new(error.to_string()))?
154 ));
155 }
156
157 let mut lines = vec![
158 "Codex Ops doctor".to_string(),
159 format!("Codex home: {}", report.codex_home),
160 format!("Auth file: {}", report.auth_file),
161 format!("Sessions dir: {}", report.sessions_dir),
162 format!("Helper dir: {}", report.helper_dir),
163 format!("Cycle file: {}", report.cycle_file),
164 String::new(),
165 ];
166
167 for check in &report.checks {
168 lines.push(format!(
169 "[{}] {}: {}",
170 check.status, check.name, check.message
171 ));
172 for detail in &check.details {
173 lines.push(format!(" {detail}"));
174 }
175 }
176
177 let errors = report
178 .checks
179 .iter()
180 .filter(|check| check.status == "error")
181 .count();
182 let warnings = report
183 .checks
184 .iter()
185 .filter(|check| check.status == "warn")
186 .count();
187 lines.push(String::new());
188 lines.push(format!("Result: {errors} error(s), {warnings} warning(s)"));
189 Ok(format!("{}\n", lines.join("\n")))
190}
191
192fn check_node_version() -> DoctorCheck {
193 match Command::new("node").arg("--version").output() {
194 Ok(output) if output.status.success() => {
195 let version = String::from_utf8_lossy(&output.stdout).trim().to_string();
196
197 if parse_node_version(&version).is_some_and(|parsed| parsed >= MIN_NODE_VERSION) {
198 ok(
199 "Node.js",
200 format!("{version} satisfies {MIN_NODE_VERSION_LABEL}"),
201 Vec::new(),
202 )
203 } else {
204 check_error(
205 "Node.js",
206 format!("{version} is below the required {MIN_NODE_VERSION_LABEL}"),
207 Vec::new(),
208 )
209 }
210 }
211 Ok(output) => check_error(
212 "Node.js",
213 String::from_utf8_lossy(&output.stderr).trim().to_string(),
214 Vec::new(),
215 ),
216 Err(error) => check_error("Node.js", error.to_string(), Vec::new()),
217 }
218}
219
220fn parse_node_version(version: &str) -> Option<NodeVersion> {
221 let mut parts = version.strip_prefix('v').unwrap_or(version).split('.');
222 let major = parts.next()?.parse::<u32>().ok()?;
223 let minor = parts.next()?.parse::<u32>().ok()?;
224 let patch_part = parts.next()?;
225 let patch_digits = patch_part
226 .chars()
227 .take_while(|ch| ch.is_ascii_digit())
228 .collect::<String>();
229 let patch = patch_digits.parse::<u32>().ok()?;
230
231 Some(NodeVersion {
232 major,
233 minor,
234 patch,
235 })
236}
237
238fn check_auth_file(auth_file: &Path, options: &DoctorOptions, now: DateTime<Utc>) -> DoctorCheck {
239 match read_codex_auth_status(
240 &AuthCommandOptions {
241 auth_file: Some(auth_file.to_path_buf()),
242 codex_home: options.codex_home.clone(),
243 store_dir: None,
244 account_history_file: None,
245 },
246 now,
247 ) {
248 Ok(report) => {
249 let summary = report.summary;
250 let label = summary
251 .email
252 .as_deref()
253 .or(summary.name.as_deref())
254 .or(summary.user_id.as_deref())
255 .unwrap_or("authenticated");
256 let mut details = vec![
257 format!(
258 "Account: {}",
259 summary
260 .chatgpt_account_id
261 .as_deref()
262 .or(summary.token_account_id.as_deref())
263 .unwrap_or("unknown")
264 ),
265 format!(
266 "Plan: {}",
267 summary.plan_type.as_deref().unwrap_or("unknown")
268 ),
269 ];
270 if let Some(expires_at) = summary.expires_at {
271 details.push(format!("Token expires: {expires_at}"));
272 }
273
274 if summary.is_expired == Some(true) {
275 warn(
276 "Auth file",
277 format!(
278 "Decoded {}, but the ID token is expired",
279 path_to_string(auth_file)
280 ),
281 details,
282 )
283 } else {
284 ok(
285 "Auth file",
286 format!("Decoded {} for {label}", path_to_string(auth_file)),
287 details,
288 )
289 }
290 }
291 Err(error) if error.message().starts_with("ENOENT:") => warn(
292 "Auth file",
293 format!("Missing auth.json at {}", path_to_string(auth_file)),
294 Vec::new(),
295 ),
296 Err(error) => check_error("Auth file", error.message().to_string(), Vec::new()),
297 }
298}
299
300fn check_directory(name: &str, path: &Path, writable: bool) -> DoctorCheck {
301 match fs::metadata(path) {
302 Ok(info) if !info.is_dir() => check_error(
303 name,
304 format!("{} exists but is not a directory", path_to_string(path)),
305 Vec::new(),
306 ),
307 Ok(_) => {
308 if let Err(error) = fs::read_dir(path) {
309 return check_error(name, error.to_string(), Vec::new());
310 }
311 if writable && is_readonly(path) {
312 return check_error(name, "permission denied".to_string(), Vec::new());
313 }
314 ok(
315 name,
316 format!("{} is accessible", path_to_string(path)),
317 Vec::new(),
318 )
319 }
320 Err(error) if error.kind() == io::ErrorKind::NotFound => warn(
321 name,
322 format!("{} does not exist", path_to_string(path)),
323 Vec::new(),
324 ),
325 Err(error) => check_error(name, error.to_string(), Vec::new()),
326 }
327}
328
329fn check_helper_directory(helper_dir: &Path) -> DoctorCheck {
330 match fs::metadata(helper_dir) {
331 Ok(info) if !info.is_dir() => check_error(
332 "Helper directory",
333 format!(
334 "{} exists but is not a directory",
335 path_to_string(helper_dir)
336 ),
337 Vec::new(),
338 ),
339 Ok(_) => {
340 if let Err(error) = fs::read_dir(helper_dir) {
341 return check_error("Helper directory", error.to_string(), Vec::new());
342 }
343 if is_readonly(helper_dir) {
344 return check_error(
345 "Helper directory",
346 "permission denied".to_string(),
347 Vec::new(),
348 );
349 }
350 ok(
351 "Helper directory",
352 format!("{} is readable and writable", path_to_string(helper_dir)),
353 Vec::new(),
354 )
355 }
356 Err(error) if error.kind() == io::ErrorKind::NotFound => ok(
357 "Helper directory",
358 format!(
359 "{} does not exist yet; helper commands will create it",
360 path_to_string(helper_dir)
361 ),
362 Vec::new(),
363 ),
364 Err(error) => check_error("Helper directory", error.to_string(), Vec::new()),
365 }
366}
367
368fn check_cycle_store(cycle_file: &Path) -> DoctorCheck {
369 let content = match fs::read_to_string(cycle_file) {
370 Ok(content) => content,
371 Err(error) if error.kind() == io::ErrorKind::NotFound => {
372 return ok(
373 "Cycle store",
374 format!("{} does not exist yet", path_to_string(cycle_file)),
375 Vec::new(),
376 )
377 }
378 Err(error) => return check_error("Cycle store", error.to_string(), Vec::new()),
379 };
380
381 match parse_cycle_store_counts(&content, cycle_file) {
382 Ok((account_count, anchor_count)) => ok(
383 "Cycle store",
384 format!("Read {}", path_to_string(cycle_file)),
385 vec![
386 format!("Accounts: {account_count}"),
387 format!("Weekly anchors: {anchor_count}"),
388 ],
389 ),
390 Err(error_message) => check_error("Cycle store", error_message, Vec::new()),
391 }
392}
393
394fn check_recent_usage(sessions_dir: &Path, now: DateTime<Utc>) -> DoctorCheck {
395 if !sessions_dir.exists() {
396 return warn(
397 "Recent usage",
398 format!(
399 "Cannot scan usage because {} does not exist",
400 path_to_string(sessions_dir)
401 ),
402 Vec::new(),
403 );
404 }
405
406 match read_recent_usage_summary(sessions_dir, now) {
407 Ok(summary) => {
408 let details = vec![
409 format!("Files read: {}", summary.read_files),
410 format!("Token events: {}", summary.token_count_events),
411 format!("Included usage events: {}", summary.included_usage_events),
412 ];
413
414 if !summary.unpriced_models.is_empty() {
415 let mut details = details;
416 for model in summary.unpriced_models.values() {
417 details.push(format!(
418 "{}: {} call(s), {} token(s){}",
419 model.model,
420 model.calls,
421 model.total_tokens,
422 model
423 .note
424 .as_ref()
425 .map(|note| format!(" ({note})"))
426 .unwrap_or_default()
427 ));
428 }
429 return warn(
430 "Recent usage",
431 format!(
432 "{} usage event(s), with unpriced model usage found",
433 summary.included_usage_events
434 ),
435 details,
436 );
437 }
438
439 if summary.included_usage_events == 0 {
440 return warn(
441 "Recent usage",
442 "No token_count usage events found in the last 7 days",
443 details,
444 );
445 }
446
447 ok(
448 "Recent usage",
449 format!(
450 "{} usage event(s) found in the last 7 days",
451 summary.included_usage_events
452 ),
453 details,
454 )
455 }
456 Err(error) => check_error("Recent usage", error, Vec::new()),
457 }
458}
459
460fn check_pricing() -> DoctorCheck {
461 let priced = list_model_pricing();
462 let unpriced_count = list_known_unpriced_models().len();
463 let mut details = vec![
464 format!("Source: {}", CODEX_RATE_CARD_SOURCE.name),
465 format!("Checked: {}", CODEX_RATE_CARD_SOURCE.checked_at),
466 format!("Credits: {}", CODEX_RATE_CARD_SOURCE.credit_to_usd),
467 ];
468
469 for model in priced.iter().filter(|model| model.note.is_some()) {
470 details.push(format!(
471 "{}: {}",
472 model.label,
473 model.note.unwrap_or_default()
474 ));
475 }
476
477 ok(
478 "Pricing",
479 format!(
480 "{} priced model(s), {} known unpriced model(s)",
481 priced.len(),
482 unpriced_count
483 ),
484 details,
485 )
486}
487
488fn read_recent_usage_summary(
489 sessions_dir: &Path,
490 now: DateTime<Utc>,
491) -> Result<RecentUsageSummary, String> {
492 let start = now - Duration::days(7);
493 let report = read_usage_records_report(&UsageRecordsReadOptions {
494 start,
495 end: now,
496 sessions_dir: sessions_dir.to_path_buf(),
497 scan_all_files: false,
498 account_history_file: None,
499 account_id: None,
500 })
501 .map_err(|error| error.message().to_string())?;
502
503 let mut summary = RecentUsageSummary {
504 read_files: report.diagnostics.read_files.max(0) as usize,
505 token_count_events: report.diagnostics.token_count_events.max(0) as usize,
506 included_usage_events: report.diagnostics.included_usage_events.max(0) as usize,
507 ..RecentUsageSummary::default()
508 };
509
510 for record in report.records {
511 let cost = calculate_credit_cost(
512 &record.model,
513 PricingTokenUsage {
514 input_tokens: record.usage.input_tokens.max(0) as u64,
515 cached_input_tokens: record.usage.cached_input_tokens.max(0) as u64,
516 output_tokens: record.usage.output_tokens.max(0) as u64,
517 },
518 );
519 if !cost.priced {
520 let key = normalize_model_name(&record.model);
521 let entry = summary
522 .unpriced_models
523 .entry(key)
524 .or_insert_with(|| RecentUnpricedModel {
525 model: record.model.clone(),
526 calls: 0,
527 total_tokens: 0,
528 note: cost.unpriced_reason.clone(),
529 });
530 entry.calls += 1;
531 entry.total_tokens += record.usage.total_tokens;
532 }
533 }
534
535 Ok(summary)
536}
537
538fn parse_cycle_store_counts(content: &str, cycle_file: &Path) -> Result<(usize, usize), String> {
539 let value: Value = serde_json::from_str(content)
540 .map_err(|error| format!("Failed to parse {}: {}", path_to_string(cycle_file), error))?;
541 let object = value.as_object().ok_or_else(|| {
542 format!(
543 "Expected {} to contain a weekly cycle store object.",
544 path_to_string(cycle_file)
545 )
546 })?;
547 if object.get("version").and_then(Value::as_i64) != Some(1) {
548 return Err(format!(
549 "Unsupported weekly cycle store version in {}: {}.",
550 path_to_string(cycle_file),
551 object
552 .get("version")
553 .map(Value::to_string)
554 .unwrap_or_else(|| "undefined".to_string())
555 ));
556 }
557 let accounts = object
558 .get("accounts")
559 .and_then(Value::as_object)
560 .ok_or_else(|| {
561 format!(
562 "Expected {} accounts to be an object.",
563 path_to_string(cycle_file)
564 )
565 })?;
566
567 let mut anchors = 0usize;
568 for account in accounts.values() {
569 anchors += account
570 .get("weekly")
571 .and_then(|weekly| weekly.get("anchors"))
572 .and_then(Value::as_array)
573 .map(Vec::len)
574 .unwrap_or_default();
575 }
576
577 Ok((accounts.len(), anchors))
578}
579
580fn ok(name: &str, message: impl Into<String>, details: Vec<String>) -> DoctorCheck {
581 DoctorCheck {
582 name: name.to_string(),
583 status: "ok".to_string(),
584 message: message.into(),
585 details,
586 }
587}
588
589fn warn(name: &str, message: impl Into<String>, details: Vec<String>) -> DoctorCheck {
590 DoctorCheck {
591 name: name.to_string(),
592 status: "warn".to_string(),
593 message: message.into(),
594 details,
595 }
596}
597
598fn check_error(name: &str, message: impl Into<String>, details: Vec<String>) -> DoctorCheck {
599 DoctorCheck {
600 name: name.to_string(),
601 status: "error".to_string(),
602 message: message.into(),
603 details,
604 }
605}
606
607fn is_readonly(path: &Path) -> bool {
608 fs::metadata(path)
609 .map(|metadata| metadata.permissions().readonly())
610 .unwrap_or(false)
611}
612
613fn format_iso(date: DateTime<Utc>) -> String {
614 date.to_rfc3339_opts(SecondsFormat::Millis, true)
615}
616
617fn path_to_string(path: &Path) -> String {
618 path.to_string_lossy().to_string()
619}
620
621#[cfg(test)]
622mod tests {
623 use super::*;
624
625 #[test]
626 fn pricing_check_matches_typescript_summary() {
627 let check = check_pricing();
628
629 assert_eq!(check.name, "Pricing");
630 assert_eq!(check.status, "ok");
631 assert_eq!(
632 check.message,
633 "8 priced model(s), 0 known unpriced model(s)"
634 );
635 assert!(check
636 .details
637 .iter()
638 .any(|detail| detail.contains("GPT-5.3-Codex-Spark")));
639 }
640
641 #[test]
642 fn parses_node_version_with_major_minor_patch() {
643 assert_eq!(
644 parse_node_version("v20.12.0"),
645 Some(NodeVersion {
646 major: 20,
647 minor: 12,
648 patch: 0
649 })
650 );
651 assert_eq!(
652 parse_node_version("20.12.1"),
653 Some(NodeVersion {
654 major: 20,
655 minor: 12,
656 patch: 1
657 })
658 );
659 assert_eq!(
660 parse_node_version("v24.15.0-pre"),
661 Some(NodeVersion {
662 major: 24,
663 minor: 15,
664 patch: 0
665 })
666 );
667 assert_eq!(parse_node_version("v20"), None);
668 assert_eq!(parse_node_version("not-node"), None);
669 }
670
671 #[test]
672 fn node_version_minimum_uses_minor_and_patch() {
673 assert!(parse_node_version("v20.12.0").is_some_and(|version| version >= MIN_NODE_VERSION));
674 assert!(parse_node_version("v20.12.1").is_some_and(|version| version >= MIN_NODE_VERSION));
675 assert!(parse_node_version("v21.0.0").is_some_and(|version| version >= MIN_NODE_VERSION));
676 assert!(parse_node_version("v20.11.9").is_some_and(|version| version < MIN_NODE_VERSION));
677 assert!(parse_node_version("v19.99.99").is_some_and(|version| version < MIN_NODE_VERSION));
678 }
679
680 #[test]
681 fn parses_cycle_store_counts() {
682 let result = parse_cycle_store_counts(
683 r#"{"version":1,"accounts":{"account-a":{"weekly":{"periodHours":168,"anchors":[{"id":"a"}]}}}}"#,
684 Path::new("/tmp/stat-cycles.json"),
685 )
686 .unwrap();
687
688 assert_eq!(result, (1, 1));
689 }
690}