mirror of
https://github.com/openai/codex.git
synced 2026-11-06 20:20:34 +00:00
## Summary Generated memory rows and their stage-one/stage-two job state currently live in `state_5.sqlite` alongside thread metadata. That makes memory cleanup and regeneration share the main state schema even though those rows are memory-pipeline data and can be rebuilt independently from the durable thread records. This PR moves the memory-owned tables into a dedicated `memories_1.sqlite` runtime database while keeping thread metadata in `state_5.sqlite`. ## Changes - Adds a separate memories DB runtime, migrator, path helpers, telemetry kind, and Bazel compile data for `state/memory_migrations`. - Introduces `MemoryStore` behind `StateRuntime::memories()` and moves memory table/job operations onto that store. - Drops the old memory tables from the state DB and recreates their schema in `state/memory_migrations/0001_memories.sql`. - Updates memory startup, citation usage tracking, rollout pollution handling, `debug clear-memories`, and app-server `memory/reset` to operate through the memories DB. - Preserves cross-DB behavior by hydrating thread metadata from the state DB when selecting visible memory outputs and checking stage-one staleness. ## Verification - Added/updated `codex-state` tests for deleted-thread memory visibility and already-polluted phase-two enqueue behavior. - Updated `debug clear-memories`, app-server `memory/reset`, and memories startup tests to seed and assert memory rows through `memories_1.sqlite`.
1694 lines
53 KiB
Rust
1694 lines
53 KiB
Rust
//! Renders doctor reports for terminal users.
|
|
//!
|
|
//! The renderer is intentionally separate from check construction so the JSON
|
|
//! report can stay stable while the human view optimizes for scanability. It
|
|
//! groups checks by concern, colors only status/actionable tokens, and redacts
|
|
//! sensitive detail lines before showing them in detailed output.
|
|
|
|
mod detail;
|
|
|
|
use std::fmt::Write as _;
|
|
|
|
use detail::HumanDetail;
|
|
use detail::detail_lines;
|
|
use owo_colors::OwoColorize;
|
|
use owo_colors::XtermColors;
|
|
|
|
use super::CheckStatus;
|
|
use super::DoctorCheck;
|
|
use super::DoctorReport;
|
|
|
|
const NAME_WIDTH: usize = 12;
|
|
const DETAIL_LABEL_WIDTH: usize = 24;
|
|
const SEPARATOR_WIDTH: usize = 61;
|
|
|
|
const GROUPS: &[OutputGroup] = &[
|
|
OutputGroup {
|
|
title: "Environment",
|
|
keys: &[
|
|
"system", "runtime", "install", "search", "git", "terminal", "title", "state",
|
|
"threads",
|
|
],
|
|
},
|
|
OutputGroup {
|
|
title: "Configuration",
|
|
keys: &["config", "auth", "mcp", "sandbox"],
|
|
},
|
|
OutputGroup {
|
|
title: "Updates",
|
|
keys: &["updates"],
|
|
},
|
|
OutputGroup {
|
|
title: "Connectivity",
|
|
keys: &["network", "websocket", "reachability"],
|
|
},
|
|
OutputGroup {
|
|
title: "Background Server",
|
|
keys: &["app-server"],
|
|
},
|
|
];
|
|
|
|
struct OutputGroup {
|
|
title: &'static str,
|
|
keys: &'static [&'static str],
|
|
}
|
|
|
|
/// Rendering controls for human doctor output.
|
|
///
|
|
/// These options affect presentation only. They must not change which checks
|
|
/// run or which fields are present in the underlying JSON report.
|
|
#[derive(Clone, Copy, Debug)]
|
|
pub(super) struct HumanOutputOptions {
|
|
pub(super) show_details: bool,
|
|
pub(super) show_all: bool,
|
|
pub(super) ascii: bool,
|
|
pub(super) color_enabled: bool,
|
|
}
|
|
|
|
/// Formats a doctor report into the grouped terminal layout.
|
|
///
|
|
/// The renderer expects checks to carry stable categories, but it owns their
|
|
/// display order. Adding a new category without adding it to GROUPS keeps JSON
|
|
/// output intact but hides that row from the human view.
|
|
pub(super) fn render_human_report(report: &DoctorReport, options: HumanOutputOptions) -> String {
|
|
let mut out = String::new();
|
|
let _ = writeln!(
|
|
out,
|
|
"{} {}",
|
|
bold("Codex Doctor", options),
|
|
dim(&header_suffix(report), options)
|
|
);
|
|
out.push('\n');
|
|
|
|
let notes = notes_for_report(report);
|
|
if !notes.is_empty() {
|
|
let _ = writeln!(out, "{}", bold("Notes", options));
|
|
for note in ¬es {
|
|
write_note_row(&mut out, note, options);
|
|
}
|
|
let _ = writeln!(out, "{}", dim(&separator(options), options));
|
|
out.push('\n');
|
|
}
|
|
|
|
let mut wrote_group = false;
|
|
for group in GROUPS {
|
|
let group_checks = checks_for_group(report, group);
|
|
if group_checks.is_empty() {
|
|
continue;
|
|
}
|
|
|
|
if wrote_group {
|
|
out.push('\n');
|
|
}
|
|
wrote_group = true;
|
|
|
|
let _ = writeln!(out, "{}", bold(group.title, options));
|
|
for check in group_checks {
|
|
write_check_row(&mut out, check, options);
|
|
}
|
|
}
|
|
|
|
out.push('\n');
|
|
let _ = writeln!(out, "{}", dim(&separator(options), options));
|
|
let _ = writeln!(out, "{}", summary_line(report, options));
|
|
out.push('\n');
|
|
write_footer(&mut out, options);
|
|
out
|
|
}
|
|
|
|
fn checks_for_group<'a>(report: &'a DoctorReport, group: &OutputGroup) -> Vec<&'a DoctorCheck> {
|
|
group
|
|
.keys
|
|
.iter()
|
|
.flat_map(|key| {
|
|
report
|
|
.checks
|
|
.iter()
|
|
.filter(move |check| check.category == *key)
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn write_check_row(out: &mut String, check: &DoctorCheck, options: HumanOutputOptions) {
|
|
let description = row_description(check, options);
|
|
let status = display_status(check);
|
|
let _ = writeln!(
|
|
out,
|
|
" {}{} {}",
|
|
status_marker_slot(status, options),
|
|
format_args!("{:<NAME_WIDTH$}", check.category),
|
|
style_description(&description, status, options)
|
|
);
|
|
|
|
if options.show_details {
|
|
for detail in detail_lines(check, options) {
|
|
write_detail_line(out, detail, options);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn write_note_row(out: &mut String, note: &DoctorNote, options: HumanOutputOptions) {
|
|
let _ = writeln!(
|
|
out,
|
|
" {}{} {}",
|
|
status_marker_slot(note.status, options),
|
|
format_args!("{:<NAME_WIDTH$}", note.name),
|
|
style_note_summary(note, options)
|
|
);
|
|
}
|
|
|
|
fn write_detail_line(out: &mut String, detail: HumanDetail, options: HumanOutputOptions) {
|
|
match detail {
|
|
HumanDetail::Row {
|
|
label,
|
|
value,
|
|
expected,
|
|
} => {
|
|
let is_issue = expected.is_some();
|
|
let label = format!("{label:<DETAIL_LABEL_WIDTH$}");
|
|
let value = if let Some(expected) = expected {
|
|
format!(
|
|
"{} {}",
|
|
detail_value(&value, options),
|
|
dim(&format!("(expected {expected})"), options)
|
|
)
|
|
} else {
|
|
detail_value(&value, options)
|
|
};
|
|
let _ = writeln!(
|
|
out,
|
|
" {} {} {}",
|
|
detail_marker(is_issue, options),
|
|
detail_label(&label, options),
|
|
value
|
|
);
|
|
}
|
|
HumanDetail::Continuation(value) => {
|
|
let spacer = " ".repeat(DETAIL_LABEL_WIDTH);
|
|
let _ = writeln!(
|
|
out,
|
|
" {} {}",
|
|
detail_label(&spacer, options),
|
|
detail_value(&value, options)
|
|
);
|
|
}
|
|
HumanDetail::Bullet(value) => {
|
|
let _ = writeln!(
|
|
out,
|
|
" {} {}",
|
|
very_dim(if options.ascii { "-" } else { "·" }, options),
|
|
dim(&highlight_actions(&value, options), options)
|
|
);
|
|
}
|
|
HumanDetail::Remedy(value) => {
|
|
let marker = if options.ascii { "->" } else { "→" };
|
|
let _ = writeln!(
|
|
out,
|
|
" {} {}",
|
|
orange(marker, options),
|
|
highlight_actions(&value, options)
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn row_description(check: &DoctorCheck, options: HumanOutputOptions) -> String {
|
|
if matches!(check.status, CheckStatus::Warning | CheckStatus::Fail) && !check.issues.is_empty()
|
|
{
|
|
return issue_summary(check);
|
|
}
|
|
if matches!(check.status, CheckStatus::Warning | CheckStatus::Fail)
|
|
&& let Some(remediation) = &check.remediation
|
|
{
|
|
let dash = if options.ascii { " - " } else { " — " };
|
|
let summary = &check.summary;
|
|
return format!("{summary}{dash}{remediation}");
|
|
}
|
|
|
|
display_summary(check, options)
|
|
}
|
|
|
|
fn issue_summary(check: &DoctorCheck) -> String {
|
|
match check.issues.as_slice() {
|
|
[] => check.summary.clone(),
|
|
[issue] => issue.cause.clone(),
|
|
issues => format!(
|
|
"{} issues - {}",
|
|
issues.len(),
|
|
issues
|
|
.iter()
|
|
.take(2)
|
|
.map(|issue| issue.cause.as_str())
|
|
.collect::<Vec<_>>()
|
|
.join("; ")
|
|
),
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
enum DisplayStatus {
|
|
Ok,
|
|
Update,
|
|
Note,
|
|
Warning,
|
|
Fail,
|
|
Idle,
|
|
}
|
|
|
|
struct DoctorNote {
|
|
status: DisplayStatus,
|
|
name: String,
|
|
summary: String,
|
|
}
|
|
|
|
fn display_status(check: &DoctorCheck) -> DisplayStatus {
|
|
if check.category == "app-server"
|
|
&& check.status == CheckStatus::Ok
|
|
&& check
|
|
.details
|
|
.iter()
|
|
.any(|detail| detail == "status: not running")
|
|
{
|
|
return DisplayStatus::Idle;
|
|
}
|
|
|
|
match check.status {
|
|
CheckStatus::Ok => DisplayStatus::Ok,
|
|
CheckStatus::Warning => DisplayStatus::Warning,
|
|
CheckStatus::Fail => DisplayStatus::Fail,
|
|
}
|
|
}
|
|
|
|
fn status_marker(status: DisplayStatus, options: HumanOutputOptions) -> String {
|
|
let marker = if options.ascii {
|
|
match status {
|
|
DisplayStatus::Ok => "[ok]",
|
|
DisplayStatus::Update => "[up]",
|
|
DisplayStatus::Note | DisplayStatus::Warning => "[!!]",
|
|
DisplayStatus::Fail => "[XX]",
|
|
DisplayStatus::Idle => "[--]",
|
|
}
|
|
} else {
|
|
match status {
|
|
DisplayStatus::Ok => "✓",
|
|
DisplayStatus::Update => "↑",
|
|
DisplayStatus::Note | DisplayStatus::Warning => "⚠",
|
|
DisplayStatus::Fail => "✗",
|
|
DisplayStatus::Idle => "○",
|
|
}
|
|
};
|
|
|
|
match status {
|
|
DisplayStatus::Ok => green(marker, options),
|
|
DisplayStatus::Update => amber(marker, options),
|
|
DisplayStatus::Note | DisplayStatus::Warning => orange(marker, options),
|
|
DisplayStatus::Fail => red(marker, options),
|
|
DisplayStatus::Idle => dim(marker, options),
|
|
}
|
|
}
|
|
|
|
fn status_marker_slot(status: DisplayStatus, options: HumanOutputOptions) -> String {
|
|
let marker = status_marker(status, options);
|
|
format!("{marker} ")
|
|
}
|
|
|
|
fn style_description(
|
|
description: &str,
|
|
status: DisplayStatus,
|
|
options: HumanOutputOptions,
|
|
) -> String {
|
|
let highlighted = highlight_actions(description, options);
|
|
match status {
|
|
DisplayStatus::Ok | DisplayStatus::Idle => dim(&highlighted, options),
|
|
DisplayStatus::Update => amber(&highlighted, options),
|
|
DisplayStatus::Note | DisplayStatus::Warning | DisplayStatus::Fail => highlighted,
|
|
}
|
|
}
|
|
|
|
fn detail_marker(is_issue: bool, options: HumanOutputOptions) -> String {
|
|
if !is_issue {
|
|
return " ".to_string();
|
|
}
|
|
orange(if options.ascii { ">" } else { "▸" }, options)
|
|
}
|
|
|
|
fn style_note_summary(note: &DoctorNote, options: HumanOutputOptions) -> String {
|
|
if note.status == DisplayStatus::Update {
|
|
return style_update_note_summary(¬e.summary, options);
|
|
}
|
|
style_description(¬e.summary, note.status, options)
|
|
}
|
|
|
|
fn style_update_note_summary(summary: &str, options: HumanOutputOptions) -> String {
|
|
if !options.color_enabled {
|
|
return summary.to_string();
|
|
}
|
|
|
|
let Some((version, rest)) = summary.split_once(" available") else {
|
|
return amber(summary, options);
|
|
};
|
|
let Some((action, parenthetical)) = rest.split_once(" (") else {
|
|
return format!(
|
|
"{}{}",
|
|
amber(&format!("{version} available"), options),
|
|
amber(rest, options)
|
|
);
|
|
};
|
|
format!(
|
|
"{}{} {}",
|
|
amber(&format!("{version} available"), options),
|
|
amber(action, options),
|
|
dim(&format!("({parenthetical}"), options)
|
|
)
|
|
}
|
|
|
|
fn summary_line(report: &DoctorReport, options: HumanOutputOptions) -> String {
|
|
let notes = notes_for_report(report);
|
|
let counts = StatusCounts::from_report(report, notes.len());
|
|
let separator = dim(if options.ascii { " | " } else { " · " }, options);
|
|
let status = overall_status_label(report.overall_status);
|
|
let mut parts = vec![count_label(counts.ok, "ok", DisplayStatus::Ok, options)];
|
|
if counts.idle > 0 {
|
|
parts.push(count_label(
|
|
counts.idle,
|
|
"idle",
|
|
DisplayStatus::Idle,
|
|
options,
|
|
));
|
|
}
|
|
if counts.notes > 0 {
|
|
parts.push(count_label(
|
|
counts.notes,
|
|
"notes",
|
|
DisplayStatus::Note,
|
|
options,
|
|
));
|
|
}
|
|
parts.push(count_label(
|
|
counts.warning,
|
|
"warn",
|
|
DisplayStatus::Warning,
|
|
options,
|
|
));
|
|
parts.push(count_label(
|
|
counts.fail,
|
|
"fail",
|
|
DisplayStatus::Fail,
|
|
options,
|
|
));
|
|
format!(
|
|
"{} {}",
|
|
parts.join(&separator),
|
|
styled_overall_status(status, report.overall_status, options)
|
|
)
|
|
}
|
|
|
|
fn count_label(
|
|
count: usize,
|
|
label: &str,
|
|
status: DisplayStatus,
|
|
options: HumanOutputOptions,
|
|
) -> String {
|
|
let count = dim(&count.to_string(), options);
|
|
let label = match status {
|
|
DisplayStatus::Ok => green(label, options),
|
|
DisplayStatus::Update => amber(label, options),
|
|
DisplayStatus::Note | DisplayStatus::Warning => orange(label, options),
|
|
DisplayStatus::Fail => red(label, options),
|
|
DisplayStatus::Idle => dim(label, options),
|
|
};
|
|
format!("{count} {label}")
|
|
}
|
|
|
|
fn overall_status_label(status: CheckStatus) -> &'static str {
|
|
match status {
|
|
CheckStatus::Ok => "ok",
|
|
CheckStatus::Warning => "degraded",
|
|
CheckStatus::Fail => "failed",
|
|
}
|
|
}
|
|
|
|
fn styled_overall_status(label: &str, status: CheckStatus, options: HumanOutputOptions) -> String {
|
|
if !options.color_enabled {
|
|
return label.to_string();
|
|
}
|
|
|
|
match status {
|
|
CheckStatus::Ok => label.green().bold().to_string(),
|
|
CheckStatus::Warning => label.yellow().bold().to_string(),
|
|
CheckStatus::Fail => label.red().bold().to_string(),
|
|
}
|
|
}
|
|
|
|
fn write_footer(out: &mut String, options: HumanOutputOptions) {
|
|
if options.show_details {
|
|
let _ = writeln!(
|
|
out,
|
|
"{} {:<24} {} {}",
|
|
cyan("--summary", options),
|
|
dim("compact output", options),
|
|
cyan("--all", options),
|
|
dim("expand truncated lists", options)
|
|
);
|
|
} else {
|
|
let _ = writeln!(
|
|
out,
|
|
"{}",
|
|
dim(
|
|
"Run codex doctor without --summary for detailed diagnostics.",
|
|
options
|
|
)
|
|
);
|
|
let _ = writeln!(
|
|
out,
|
|
"{} {:<28} {} {}",
|
|
cyan("--all", options),
|
|
dim("expand truncated lists", options),
|
|
cyan("--json", options),
|
|
dim("redacted report", options)
|
|
);
|
|
return;
|
|
}
|
|
let _ = writeln!(
|
|
out,
|
|
"{} {}",
|
|
cyan("--json", options),
|
|
dim("redacted report", options)
|
|
);
|
|
}
|
|
|
|
fn header_suffix(report: &DoctorReport) -> String {
|
|
let version = format!("v{}", report.codex_version);
|
|
report
|
|
.checks
|
|
.iter()
|
|
.find(|check| check.category == "runtime")
|
|
.and_then(|check| detail::detail_value(check, "platform"))
|
|
.map_or(version.clone(), |platform| {
|
|
format!("{version} · {platform}")
|
|
})
|
|
}
|
|
|
|
fn notes_for_report(report: &DoctorReport) -> Vec<DoctorNote> {
|
|
let mut notes = Vec::new();
|
|
if let Some(check) = find_check(report, "updates") {
|
|
update_note(check, report)
|
|
.into_iter()
|
|
.for_each(|note| notes.push(note));
|
|
}
|
|
if let Some(check) = find_check(report, "state") {
|
|
rollout_note(check)
|
|
.into_iter()
|
|
.for_each(|note| notes.push(note));
|
|
}
|
|
if let Some(check) = find_check(report, "sandbox") {
|
|
sandbox_note(check)
|
|
.into_iter()
|
|
.for_each(|note| notes.push(note));
|
|
}
|
|
non_ok_notes(report)
|
|
.into_iter()
|
|
.for_each(|note| notes.push(note));
|
|
auth_reachability_note(report)
|
|
.into_iter()
|
|
.for_each(|note| notes.push(note));
|
|
notes
|
|
}
|
|
|
|
fn find_check<'a>(report: &'a DoctorReport, category: &str) -> Option<&'a DoctorCheck> {
|
|
report
|
|
.checks
|
|
.iter()
|
|
.find(|check| check.category == category)
|
|
}
|
|
|
|
fn update_note(check: &DoctorCheck, report: &DoctorReport) -> Option<DoctorNote> {
|
|
let status = detail::detail_value(check, "latest version status")?;
|
|
if !status.contains("newer version is available") {
|
|
return None;
|
|
}
|
|
let latest = detail::detail_value(check, "latest version")
|
|
.or_else(|| detail::detail_value(check, "cached latest version"))
|
|
.unwrap_or_else(|| "newer version".to_string());
|
|
let dismissed = detail::detail_value(check, "dismissed version");
|
|
let mut parenthetical = format!("current {}", report.codex_version);
|
|
if let Some(dismissed) = dismissed
|
|
&& !detail::is_falsy(&dismissed)
|
|
{
|
|
parenthetical.push_str(&format!(", dismissed {dismissed}"));
|
|
}
|
|
Some(DoctorNote {
|
|
status: DisplayStatus::Update,
|
|
name: "updates".to_string(),
|
|
summary: format!("{latest} available ({parenthetical})"),
|
|
})
|
|
}
|
|
|
|
fn rollout_note(check: &DoctorCheck) -> Option<DoctorNote> {
|
|
let active = detail::detail_value(check, "active rollout files")?;
|
|
let (files, bytes) = detail::rollout_files_and_bytes(&active)?;
|
|
if files < 1000 && bytes < 1024 * 1024 * 1024 {
|
|
return None;
|
|
}
|
|
Some(DoctorNote {
|
|
status: DisplayStatus::Warning,
|
|
name: "rollouts".to_string(),
|
|
summary: format!(
|
|
"{} active files · {} on disk",
|
|
detail::format_count(files),
|
|
detail::format_bytes(bytes)
|
|
),
|
|
})
|
|
}
|
|
|
|
fn sandbox_note(check: &DoctorCheck) -> Option<DoctorNote> {
|
|
let filesystem = detail::detail_value(check, "filesystem sandbox")?;
|
|
let network = detail::detail_value(check, "network sandbox")?;
|
|
if filesystem == "restricted" && network == "restricted" {
|
|
return None;
|
|
}
|
|
Some(DoctorNote {
|
|
status: DisplayStatus::Warning,
|
|
name: "sandbox".to_string(),
|
|
summary: format!("filesystem {filesystem} · network {network}"),
|
|
})
|
|
}
|
|
|
|
fn non_ok_notes(report: &DoctorReport) -> Vec<DoctorNote> {
|
|
report
|
|
.checks
|
|
.iter()
|
|
.filter(|check| matches!(check.status, CheckStatus::Warning | CheckStatus::Fail))
|
|
.map(|check| DoctorNote {
|
|
status: display_status(check),
|
|
name: check.category.clone(),
|
|
summary: actionable_note_summary(check),
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn actionable_note_summary(check: &DoctorCheck) -> String {
|
|
if !check.issues.is_empty() {
|
|
return issue_summary(check);
|
|
}
|
|
if let Some(remediation) = &check.remediation {
|
|
return format!("{} - {remediation}", check.summary);
|
|
}
|
|
check.summary.clone()
|
|
}
|
|
|
|
fn auth_reachability_note(report: &DoctorReport) -> Option<DoctorNote> {
|
|
let websocket = find_check(report, "websocket")?;
|
|
let reachability = find_check(report, "reachability")?;
|
|
let auth_mode = detail::detail_value(websocket, "auth mode")?;
|
|
let reachability_mode = detail::detail_value(reachability, "reachability mode")?;
|
|
let auth_mode_lower = auth_mode.to_ascii_lowercase();
|
|
let reachability_mode_lower = reachability_mode.to_ascii_lowercase();
|
|
if auth_mode_lower.contains("chatgpt") && reachability_mode_lower.contains("api key") {
|
|
return Some(DoctorNote {
|
|
status: DisplayStatus::Warning,
|
|
name: "auth".to_string(),
|
|
summary: "mixed auth signals: ChatGPT login plus API key env var; HTTP reachability uses API-key mode".to_string(),
|
|
});
|
|
}
|
|
None
|
|
}
|
|
|
|
fn display_summary(check: &DoctorCheck, options: HumanOutputOptions) -> String {
|
|
match check.category.as_str() {
|
|
"system" => system_summary(check),
|
|
"runtime" => runtime_summary(check),
|
|
"install" if check.status == CheckStatus::Ok => "consistent".to_string(),
|
|
"search" => search_summary(check),
|
|
"git" => git_summary(check),
|
|
"terminal" => terminal_summary(check),
|
|
"title" => title_summary(check, options),
|
|
"state" => state_summary(check),
|
|
"config" if check.status == CheckStatus::Ok => "loaded".to_string(),
|
|
"mcp" => mcp_summary(check),
|
|
"sandbox" => sandbox_summary(check),
|
|
"network" => network_summary(check),
|
|
"websocket" => websocket_summary(check),
|
|
"app-server" => app_server_summary(check),
|
|
_ => check.summary.clone(),
|
|
}
|
|
}
|
|
|
|
fn system_summary(check: &DoctorCheck) -> String {
|
|
detail::detail_value(check, "os language").unwrap_or_else(|| check.summary.clone())
|
|
}
|
|
|
|
fn runtime_summary(check: &DoctorCheck) -> String {
|
|
if detail::detail_value(check, "current executable")
|
|
.is_some_and(|path| path.contains("/target/debug/"))
|
|
{
|
|
return "local debug build".to_string();
|
|
}
|
|
detail::detail_value(check, "install method").unwrap_or_else(|| check.summary.clone())
|
|
}
|
|
|
|
fn search_summary(check: &DoctorCheck) -> String {
|
|
let provider = detail::detail_value(check, "search provider");
|
|
let command = detail::detail_value(check, "search command");
|
|
let readiness = detail::detail_value(check, "search command readiness");
|
|
match (readiness, provider, command) {
|
|
(Some(readiness), Some(provider), Some(command)) if check.status == CheckStatus::Ok => {
|
|
format!("{readiness} ({provider}, `{command}`)")
|
|
}
|
|
_ => check.summary.clone(),
|
|
}
|
|
}
|
|
|
|
fn git_summary(check: &DoctorCheck) -> String {
|
|
detail::detail_value(check, "git version")
|
|
.or_else(|| detail::detail_value(check, "selected git"))
|
|
.unwrap_or_else(|| check.summary.clone())
|
|
}
|
|
|
|
fn terminal_summary(check: &DoctorCheck) -> String {
|
|
let mut parts = Vec::new();
|
|
if let Some(terminal) = detail::detail_value(check, "terminal") {
|
|
let version = detail::detail_value(check, "terminal version");
|
|
parts.push(version.map_or(terminal.clone(), |version| format!("{terminal} {version}")));
|
|
}
|
|
if let Some(multiplexer) = detail::detail_value(check, "multiplexer") {
|
|
parts.push(multiplexer);
|
|
}
|
|
if let Some(term) = detail::detail_value(check, "TERM") {
|
|
parts.push(format!("TERM={term}"));
|
|
}
|
|
if parts.is_empty() {
|
|
check.summary.clone()
|
|
} else {
|
|
parts.join(" · ")
|
|
}
|
|
}
|
|
|
|
fn title_summary(check: &DoctorCheck, options: HumanOutputOptions) -> String {
|
|
let source = detail::detail_value(check, "terminal title source");
|
|
let project = detail::detail_value(check, "terminal title project value");
|
|
match (source, project) {
|
|
(Some(source), Some(project)) => {
|
|
let separator = if options.ascii { " | " } else { " · " };
|
|
format!("{source}{separator}project {project}")
|
|
}
|
|
(Some(source), None) => source,
|
|
_ => check.summary.clone(),
|
|
}
|
|
}
|
|
|
|
fn state_summary(check: &DoctorCheck) -> String {
|
|
let databases_ok = [
|
|
"state DB integrity",
|
|
"log DB integrity",
|
|
"goals DB integrity",
|
|
"memories DB integrity",
|
|
]
|
|
.into_iter()
|
|
.all(|label| detail::detail_value(check, label).is_some_and(|value| value == "ok"));
|
|
if check.status == CheckStatus::Ok && databases_ok {
|
|
"databases healthy".to_string()
|
|
} else {
|
|
check.summary.clone()
|
|
}
|
|
}
|
|
|
|
fn mcp_summary(check: &DoctorCheck) -> String {
|
|
let Some(count) = detail::detail_value(check, "configured servers") else {
|
|
return check.summary.clone();
|
|
};
|
|
let disabled =
|
|
detail::detail_value(check, "disabled servers").unwrap_or_else(|| "0".to_string());
|
|
let transports = check
|
|
.details
|
|
.iter()
|
|
.filter_map(|detail| detail.split_once(" servers: "))
|
|
.filter(|(transport, _)| *transport != "configured" && *transport != "disabled")
|
|
.map(|(transport, count)| format!("{count} {transport}"))
|
|
.collect::<Vec<_>>();
|
|
if transports.is_empty() {
|
|
format!("{count} servers · {disabled} disabled")
|
|
} else {
|
|
format!(
|
|
"{} server ({}) · {} disabled",
|
|
count,
|
|
transports.join(", "),
|
|
disabled
|
|
)
|
|
}
|
|
}
|
|
|
|
fn sandbox_summary(check: &DoctorCheck) -> String {
|
|
let approval = detail::detail_value(check, "approval policy");
|
|
let filesystem = detail::detail_value(check, "filesystem sandbox");
|
|
let network = detail::detail_value(check, "network sandbox");
|
|
match (approval, filesystem, network) {
|
|
(Some(approval), Some(filesystem), Some(network)) => {
|
|
format!("{filesystem} fs + {network} network · approval {approval}")
|
|
}
|
|
_ => check.summary.clone(),
|
|
}
|
|
}
|
|
|
|
fn network_summary(check: &DoctorCheck) -> String {
|
|
detail::detail_value(check, "proxy env vars")
|
|
.map(|value| {
|
|
if value == "none" {
|
|
"no proxy env vars".to_string()
|
|
} else {
|
|
"proxy env vars present".to_string()
|
|
}
|
|
})
|
|
.unwrap_or_else(|| check.summary.clone())
|
|
}
|
|
|
|
fn websocket_summary(check: &DoctorCheck) -> String {
|
|
let status = detail::detail_value(check, "handshake result")
|
|
.or_else(|| detail::detail_value(check, "handshake status"));
|
|
let timeout = detail::detail_value(check, "connect timeout")
|
|
.map(|value| value.replace("000 ms", "s").replace(" ms", "ms"));
|
|
match (status, timeout) {
|
|
(Some(status), Some(timeout)) => format!("connected ({status}) · {timeout} timeout"),
|
|
_ => check.summary.clone(),
|
|
}
|
|
}
|
|
|
|
fn app_server_summary(check: &DoctorCheck) -> String {
|
|
let status = detail::detail_value(check, "status");
|
|
let mode = detail::detail_value(check, "mode");
|
|
match (status, mode) {
|
|
(Some(status), Some(mode)) => format!("{status} ({mode} mode)"),
|
|
_ => check.summary.clone(),
|
|
}
|
|
}
|
|
|
|
fn separator(options: HumanOutputOptions) -> String {
|
|
if options.ascii {
|
|
"-".repeat(SEPARATOR_WIDTH)
|
|
} else {
|
|
"─".repeat(SEPARATOR_WIDTH)
|
|
}
|
|
}
|
|
|
|
fn highlight_actions(text: &str, options: HumanOutputOptions) -> String {
|
|
if !options.color_enabled {
|
|
return text.to_string();
|
|
}
|
|
|
|
let mut out = String::new();
|
|
let mut parts = text.split('`');
|
|
if let Some(first) = parts.next() {
|
|
out.push_str(&highlight_flags(first, options));
|
|
}
|
|
let mut in_code = true;
|
|
for part in parts {
|
|
if in_code {
|
|
out.push_str(&cyan(part, options));
|
|
} else {
|
|
out.push_str(&highlight_flags(part, options));
|
|
}
|
|
in_code = !in_code;
|
|
}
|
|
out
|
|
}
|
|
|
|
fn highlight_flags(text: &str, options: HumanOutputOptions) -> String {
|
|
text.split_inclusive(char::is_whitespace)
|
|
.map(|token| {
|
|
let trimmed = token.trim_end();
|
|
let suffix = &token[trimmed.len()..];
|
|
let bare = trimmed.trim_end_matches([',', '.', ':', ';', ')']);
|
|
let punctuation = &trimmed[bare.len()..];
|
|
if bare.starts_with("--") {
|
|
let highlighted = cyan(bare, options);
|
|
format!("{highlighted}{punctuation}{suffix}")
|
|
} else {
|
|
token.to_string()
|
|
}
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
pub(super) fn redact_detail(detail: &str) -> String {
|
|
let lower = detail.to_ascii_lowercase();
|
|
let label = lower.split(':').next().unwrap_or_default();
|
|
if label.contains("env var") {
|
|
return redact_urls(detail);
|
|
}
|
|
if detail
|
|
.split_once(": ")
|
|
.is_some_and(|(_, value)| is_safe_presence_value(value))
|
|
{
|
|
return redact_urls(detail);
|
|
}
|
|
|
|
let secret_keys = [
|
|
"openai_api_key",
|
|
"codex_api_key",
|
|
"codex_access_token",
|
|
"authorization",
|
|
"bearer_token",
|
|
"token",
|
|
"secret",
|
|
];
|
|
if secret_keys.iter().any(|key| lower.contains(key)) {
|
|
let name = detail.split(':').next().unwrap_or(detail);
|
|
format!("{name}: <redacted>")
|
|
} else {
|
|
redact_urls(detail)
|
|
}
|
|
}
|
|
|
|
fn is_safe_presence_value(value: &str) -> bool {
|
|
matches!(
|
|
value.trim().to_ascii_lowercase().as_str(),
|
|
"true" | "false" | "yes" | "no" | "present" | "absent" | "missing" | "not set"
|
|
)
|
|
}
|
|
|
|
fn redact_urls(detail: &str) -> String {
|
|
detail
|
|
.split_inclusive(char::is_whitespace)
|
|
.map(redact_url_token)
|
|
.collect()
|
|
}
|
|
|
|
fn redact_url_token(token: &str) -> String {
|
|
let Some(scheme_end) = token.find("://") else {
|
|
return token.to_string();
|
|
};
|
|
let mut suffix_start = token.len();
|
|
while suffix_start > scheme_end + 3
|
|
&& matches!(
|
|
token.as_bytes()[suffix_start - 1],
|
|
b' ' | b'\t' | b'\n' | b'\r' | b'.' | b',' | b';' | b':' | b')' | b']'
|
|
)
|
|
{
|
|
suffix_start -= 1;
|
|
}
|
|
|
|
let (body, suffix) = token.split_at(suffix_start);
|
|
let scheme_prefix_end = scheme_end + 3;
|
|
let rest = &body[scheme_prefix_end..];
|
|
let authority_end = rest
|
|
.find(['/', '?', '#'])
|
|
.map(|index| scheme_prefix_end + index)
|
|
.unwrap_or(body.len());
|
|
let authority = &body[scheme_prefix_end..authority_end];
|
|
let authority = authority
|
|
.rsplit_once('@')
|
|
.map_or(authority, |(_, host)| host);
|
|
let path = &body[authority_end..];
|
|
let path = path
|
|
.find(['?', '#'])
|
|
.map(|index| &path[..index])
|
|
.unwrap_or(path);
|
|
let path = redact_url_path(path);
|
|
format!(
|
|
"{}{}{}{}",
|
|
&body[..scheme_prefix_end],
|
|
authority,
|
|
path,
|
|
suffix
|
|
)
|
|
}
|
|
|
|
fn redact_url_path(path: &str) -> String {
|
|
let mut segments = path.split('/').filter(|segment| !segment.is_empty());
|
|
let Some(first_segment) = segments.next() else {
|
|
return path.to_string();
|
|
};
|
|
if segments.next().is_some() {
|
|
format!("/{first_segment}/<redacted>")
|
|
} else {
|
|
path.to_string()
|
|
}
|
|
}
|
|
|
|
#[derive(Default)]
|
|
struct StatusCounts {
|
|
ok: usize,
|
|
idle: usize,
|
|
notes: usize,
|
|
warning: usize,
|
|
fail: usize,
|
|
}
|
|
|
|
impl StatusCounts {
|
|
fn from_report(report: &DoctorReport, notes: usize) -> Self {
|
|
let mut counts = Self {
|
|
notes,
|
|
..Self::default()
|
|
};
|
|
for check in &report.checks {
|
|
match display_status(check) {
|
|
DisplayStatus::Ok => counts.ok += 1,
|
|
DisplayStatus::Idle => counts.idle += 1,
|
|
DisplayStatus::Warning => counts.warning += 1,
|
|
DisplayStatus::Fail => counts.fail += 1,
|
|
DisplayStatus::Update | DisplayStatus::Note => {}
|
|
}
|
|
}
|
|
counts
|
|
}
|
|
}
|
|
|
|
fn bold(text: &str, options: HumanOutputOptions) -> String {
|
|
if options.color_enabled {
|
|
text.bold().to_string()
|
|
} else {
|
|
text.to_string()
|
|
}
|
|
}
|
|
|
|
fn dim(text: &str, options: HumanOutputOptions) -> String {
|
|
if options.color_enabled {
|
|
text.dimmed().to_string()
|
|
} else {
|
|
text.to_string()
|
|
}
|
|
}
|
|
|
|
fn very_dim(text: &str, options: HumanOutputOptions) -> String {
|
|
color256(text, /*code*/ 238, options)
|
|
}
|
|
|
|
fn detail_label(text: &str, options: HumanOutputOptions) -> String {
|
|
color256(text, /*code*/ 240, options)
|
|
}
|
|
|
|
fn detail_value(text: &str, options: HumanOutputOptions) -> String {
|
|
if !options.color_enabled {
|
|
return text.to_string();
|
|
}
|
|
style_detail_text(text, options)
|
|
}
|
|
|
|
fn style_detail_text(text: &str, options: HumanOutputOptions) -> String {
|
|
let mut out = String::new();
|
|
let mut parts = text.split('`');
|
|
if let Some(first) = parts.next() {
|
|
out.push_str(&style_detail_plain_text(first, options));
|
|
}
|
|
let mut in_code = true;
|
|
for part in parts {
|
|
if in_code {
|
|
out.push_str(&cyan(part, options));
|
|
} else {
|
|
out.push_str(&style_detail_plain_text(part, options));
|
|
}
|
|
in_code = !in_code;
|
|
}
|
|
out
|
|
}
|
|
|
|
fn style_detail_plain_text(text: &str, options: HumanOutputOptions) -> String {
|
|
text.split_inclusive(char::is_whitespace)
|
|
.map(|token| style_detail_token(token, options))
|
|
.collect()
|
|
}
|
|
|
|
fn style_detail_token(token: &str, options: HumanOutputOptions) -> String {
|
|
let trimmed = token.trim_end();
|
|
let suffix = &token[trimmed.len()..];
|
|
let bare = trimmed.trim_end_matches([',', '.', ':', ';', ')']);
|
|
let punctuation = &trimmed[bare.len()..];
|
|
let styled = style_detail_bare_token(bare, options);
|
|
format!("{styled}{punctuation}{suffix}")
|
|
}
|
|
|
|
fn style_detail_bare_token(bare: &str, options: HumanOutputOptions) -> String {
|
|
if bare.is_empty() {
|
|
return String::new();
|
|
}
|
|
if bare == "<redacted>" {
|
|
return color256(&bare.italic().to_string(), /*code*/ 244, options);
|
|
}
|
|
if bare.contains("(missing)") || detail::is_falsy(bare) {
|
|
return color256(bare, /*code*/ 240, options);
|
|
}
|
|
if let Some((label, value)) = bare.split_once(':')
|
|
&& detail::is_falsy(value)
|
|
{
|
|
return format!("{label}:{}", color256(value, /*code*/ 240, options));
|
|
}
|
|
if bare == "ok" {
|
|
return green(bare, options);
|
|
}
|
|
if bare.starts_with("--") || looks_copyable(bare) {
|
|
return cyan(bare, options);
|
|
}
|
|
if matches!(bare, "B" | "KB" | "MB" | "GB" | "TB" | "files" | "file") {
|
|
return dim(bare, options);
|
|
}
|
|
bare.to_string()
|
|
}
|
|
|
|
fn green(text: &str, options: HumanOutputOptions) -> String {
|
|
color256(text, /*code*/ 10, options)
|
|
}
|
|
|
|
fn amber(text: &str, options: HumanOutputOptions) -> String {
|
|
color256(text, /*code*/ 220, options)
|
|
}
|
|
|
|
fn orange(text: &str, options: HumanOutputOptions) -> String {
|
|
color256(text, /*code*/ 214, options)
|
|
}
|
|
|
|
fn red(text: &str, options: HumanOutputOptions) -> String {
|
|
color256(text, /*code*/ 196, options)
|
|
}
|
|
|
|
fn cyan(text: &str, options: HumanOutputOptions) -> String {
|
|
color256(text, /*code*/ 117, options)
|
|
}
|
|
|
|
fn color256(text: &str, code: u8, options: HumanOutputOptions) -> String {
|
|
if options.color_enabled {
|
|
text.color(XtermColors::from(code)).to_string()
|
|
} else {
|
|
text.to_string()
|
|
}
|
|
}
|
|
|
|
fn looks_copyable(text: &str) -> bool {
|
|
text.starts_with("http://")
|
|
|| text.starts_with("https://")
|
|
|| text.starts_with("wss://")
|
|
|| text.starts_with("~/")
|
|
|| text.starts_with('/')
|
|
|| text.starts_with("./")
|
|
|| text.starts_with("../")
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use pretty_assertions::assert_eq;
|
|
|
|
use super::*;
|
|
|
|
fn detailed_no_color_unicode_options() -> HumanOutputOptions {
|
|
HumanOutputOptions {
|
|
show_details: true,
|
|
show_all: false,
|
|
ascii: false,
|
|
color_enabled: false,
|
|
}
|
|
}
|
|
|
|
fn summary_no_color_unicode_options() -> HumanOutputOptions {
|
|
HumanOutputOptions {
|
|
show_details: false,
|
|
show_all: false,
|
|
ascii: false,
|
|
color_enabled: false,
|
|
}
|
|
}
|
|
|
|
fn detailed_all_no_color_unicode_options() -> HumanOutputOptions {
|
|
HumanOutputOptions {
|
|
show_details: true,
|
|
show_all: true,
|
|
ascii: false,
|
|
color_enabled: false,
|
|
}
|
|
}
|
|
|
|
fn detailed_color_unicode_options() -> HumanOutputOptions {
|
|
HumanOutputOptions {
|
|
show_details: true,
|
|
show_all: false,
|
|
ascii: false,
|
|
color_enabled: true,
|
|
}
|
|
}
|
|
|
|
fn sample_report() -> DoctorReport {
|
|
let checks = vec![
|
|
DoctorCheck::new(
|
|
"system.environment",
|
|
"system",
|
|
CheckStatus::Ok,
|
|
"OS language en-US",
|
|
)
|
|
.detail("os: macOS 15.0")
|
|
.detail("os language: en-US"),
|
|
DoctorCheck::new(
|
|
"runtime.provenance",
|
|
"runtime",
|
|
CheckStatus::Ok,
|
|
"running local build on darwin-arm64",
|
|
),
|
|
DoctorCheck::new(
|
|
"installation",
|
|
"install",
|
|
CheckStatus::Ok,
|
|
"installation looks consistent",
|
|
),
|
|
DoctorCheck::new(
|
|
"runtime.search",
|
|
"search",
|
|
CheckStatus::Ok,
|
|
"search is OK (bundled)",
|
|
),
|
|
DoctorCheck::new(
|
|
"git.environment",
|
|
"git",
|
|
CheckStatus::Ok,
|
|
"git version 2.54.0",
|
|
)
|
|
.detail("selected git: /usr/bin/git")
|
|
.detail("git version: git version 2.54.0")
|
|
.detail("repo detected: true"),
|
|
DoctorCheck::new(
|
|
"terminal.env",
|
|
"terminal",
|
|
CheckStatus::Warning,
|
|
"narrow terminal",
|
|
),
|
|
DoctorCheck::new(
|
|
"terminal.title",
|
|
"title",
|
|
CheckStatus::Ok,
|
|
"terminal title default",
|
|
)
|
|
.detail("terminal title source: default")
|
|
.detail("terminal title items: activity, project-name")
|
|
.detail("terminal title project value: codex"),
|
|
DoctorCheck::new(
|
|
"state.paths",
|
|
"state",
|
|
CheckStatus::Ok,
|
|
"state paths inspectable",
|
|
),
|
|
DoctorCheck::new(
|
|
"auth.credentials",
|
|
"auth",
|
|
CheckStatus::Fail,
|
|
"token expired",
|
|
)
|
|
.detail("OPENAI_API_KEY: present")
|
|
.remediation("Run `codex login`."),
|
|
DoctorCheck::new(
|
|
"updates.status",
|
|
"updates",
|
|
CheckStatus::Ok,
|
|
"update configuration is locally consistent",
|
|
),
|
|
DoctorCheck::new(
|
|
"network.env",
|
|
"network",
|
|
CheckStatus::Ok,
|
|
"network environment readable",
|
|
),
|
|
DoctorCheck::new(
|
|
"network.websocket_reachability",
|
|
"websocket",
|
|
CheckStatus::Ok,
|
|
"Responses WebSocket handshake succeeded",
|
|
),
|
|
DoctorCheck::new(
|
|
"app_server.status",
|
|
"app-server",
|
|
CheckStatus::Ok,
|
|
"background server is not running",
|
|
),
|
|
DoctorCheck::new(
|
|
"network.provider_reachability",
|
|
"reachability",
|
|
CheckStatus::Ok,
|
|
"active provider endpoints are reachable over HTTP",
|
|
),
|
|
];
|
|
DoctorReport {
|
|
schema_version: 1,
|
|
generated_at: "0s since unix epoch".to_string(),
|
|
overall_status: CheckStatus::Fail,
|
|
codex_version: "0.0.0".to_string(),
|
|
checks,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn render_human_report_includes_details_by_default_without_color() {
|
|
let rendered = render_human_report(&sample_report(), detailed_no_color_unicode_options());
|
|
let expected = format!(
|
|
"\
|
|
Codex Doctor v0.0.0
|
|
|
|
Notes
|
|
⚠ terminal narrow terminal
|
|
✗ auth token expired - Run `codex login`.
|
|
─────────────────────────────────────────────────────────────
|
|
|
|
Environment
|
|
✓ system en-US
|
|
os macOS 15.0
|
|
OS language en-US
|
|
✓ runtime running local build on darwin-arm64
|
|
✓ install consistent
|
|
managed by npm: no · bun: no · package root —
|
|
✓ search search is OK (bundled)
|
|
✓ git git version 2.54.0
|
|
selected git /usr/bin/git
|
|
version git version 2.54.0
|
|
repo detected true
|
|
⚠ terminal narrow terminal
|
|
✓ title default · project codex
|
|
title source default
|
|
title items activity, project-name
|
|
project value codex
|
|
✓ state state paths inspectable
|
|
|
|
Configuration
|
|
✗ auth token expired — Run `codex login`.
|
|
OPENAI_API_KEY present
|
|
|
|
Updates
|
|
✓ updates update configuration is locally consistent
|
|
|
|
Connectivity
|
|
✓ network network environment readable
|
|
✓ websocket Responses WebSocket handshake succeeded
|
|
✓ reachability active provider endpoints are reachable over HTTP
|
|
|
|
Background Server
|
|
✓ app-server background server is not running
|
|
|
|
{}
|
|
12 ok · 2 notes · 1 warn · 1 fail failed
|
|
|
|
--summary compact output --all expand truncated lists
|
|
--json redacted report
|
|
",
|
|
"─".repeat(SEPARATOR_WIDTH)
|
|
);
|
|
assert_eq!(rendered, expected);
|
|
}
|
|
|
|
#[test]
|
|
fn render_human_report_snapshot_covers_environment_rows() {
|
|
insta::assert_snapshot!(
|
|
"doctor_human_report_environment_rows",
|
|
render_human_report(&sample_report(), detailed_no_color_unicode_options())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn render_human_report_supports_summary_output_without_color() {
|
|
let rendered = render_human_report(&sample_report(), summary_no_color_unicode_options());
|
|
let expected = format!(
|
|
"\
|
|
Codex Doctor v0.0.0
|
|
|
|
Notes
|
|
⚠ terminal narrow terminal
|
|
✗ auth token expired - Run `codex login`.
|
|
─────────────────────────────────────────────────────────────
|
|
|
|
Environment
|
|
✓ system en-US
|
|
✓ runtime running local build on darwin-arm64
|
|
✓ install consistent
|
|
✓ search search is OK (bundled)
|
|
✓ git git version 2.54.0
|
|
⚠ terminal narrow terminal
|
|
✓ title default · project codex
|
|
✓ state state paths inspectable
|
|
|
|
Configuration
|
|
✗ auth token expired — Run `codex login`.
|
|
|
|
Updates
|
|
✓ updates update configuration is locally consistent
|
|
|
|
Connectivity
|
|
✓ network network environment readable
|
|
✓ websocket Responses WebSocket handshake succeeded
|
|
✓ reachability active provider endpoints are reachable over HTTP
|
|
|
|
Background Server
|
|
✓ app-server background server is not running
|
|
|
|
{}
|
|
12 ok · 2 notes · 1 warn · 1 fail failed
|
|
|
|
Run codex doctor without --summary for detailed diagnostics.
|
|
--all expand truncated lists --json redacted report
|
|
",
|
|
"─".repeat(SEPARATOR_WIDTH)
|
|
);
|
|
assert_eq!(rendered, expected);
|
|
}
|
|
|
|
#[test]
|
|
fn render_human_report_includes_threads_row_in_environment() {
|
|
let mut report = sample_report();
|
|
report.checks.push(DoctorCheck::new(
|
|
"state.rollout_db_parity",
|
|
"threads",
|
|
CheckStatus::Warning,
|
|
"rollout files and state DB thread inventory differ",
|
|
));
|
|
|
|
let rendered = render_human_report(&report, summary_no_color_unicode_options());
|
|
|
|
let threads_line = rendered
|
|
.lines()
|
|
.find(|line| line.contains("threads"))
|
|
.expect("threads row should be rendered");
|
|
assert!(
|
|
threads_line.contains("rollout files and state DB thread inventory differ"),
|
|
"{threads_line}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn render_human_report_includes_memories_db_in_state_health_summary() {
|
|
let report = DoctorReport {
|
|
schema_version: 1,
|
|
generated_at: "0s since unix epoch".to_string(),
|
|
overall_status: CheckStatus::Ok,
|
|
codex_version: "0.0.0".to_string(),
|
|
checks: vec![
|
|
DoctorCheck::new(
|
|
"state.paths",
|
|
"state",
|
|
CheckStatus::Ok,
|
|
"state paths inspectable",
|
|
)
|
|
.detail("state DB: /tmp/state.sqlite")
|
|
.detail("state DB integrity: ok")
|
|
.detail("log DB: /tmp/logs.sqlite")
|
|
.detail("log DB integrity: ok")
|
|
.detail("goals DB: /tmp/goals.sqlite")
|
|
.detail("goals DB integrity: ok")
|
|
.detail("memories DB: /tmp/memories.sqlite")
|
|
.detail("memories DB integrity: ok"),
|
|
],
|
|
};
|
|
|
|
let rendered = render_human_report(&report, detailed_no_color_unicode_options());
|
|
|
|
assert!(rendered.contains("✓ state databases healthy"));
|
|
assert!(rendered.contains("memories DB /tmp/memories.sqlite · integrity ok"));
|
|
}
|
|
|
|
#[test]
|
|
fn render_human_report_supports_ascii_output() {
|
|
let rendered = render_human_report(
|
|
&sample_report(),
|
|
HumanOutputOptions {
|
|
show_details: false,
|
|
show_all: false,
|
|
ascii: true,
|
|
color_enabled: false,
|
|
},
|
|
);
|
|
let expected = format!(
|
|
"\
|
|
Codex Doctor v0.0.0
|
|
|
|
Notes
|
|
[!!] terminal narrow terminal
|
|
[XX] auth token expired - Run `codex login`.
|
|
-------------------------------------------------------------
|
|
|
|
Environment
|
|
[ok] system en-US
|
|
[ok] runtime running local build on darwin-arm64
|
|
[ok] install consistent
|
|
[ok] search search is OK (bundled)
|
|
[ok] git git version 2.54.0
|
|
[!!] terminal narrow terminal
|
|
[ok] title default | project codex
|
|
[ok] state state paths inspectable
|
|
|
|
Configuration
|
|
[XX] auth token expired - Run `codex login`.
|
|
|
|
Updates
|
|
[ok] updates update configuration is locally consistent
|
|
|
|
Connectivity
|
|
[ok] network network environment readable
|
|
[ok] websocket Responses WebSocket handshake succeeded
|
|
[ok] reachability active provider endpoints are reachable over HTTP
|
|
|
|
Background Server
|
|
[ok] app-server background server is not running
|
|
|
|
{}
|
|
12 ok | 2 notes | 1 warn | 1 fail failed
|
|
|
|
Run codex doctor without --summary for detailed diagnostics.
|
|
--all expand truncated lists --json redacted report
|
|
",
|
|
"-".repeat(SEPARATOR_WIDTH)
|
|
);
|
|
assert_eq!(rendered, expected);
|
|
}
|
|
|
|
#[test]
|
|
fn render_human_report_includes_redacted_details() {
|
|
let rendered = render_human_report(
|
|
&sample_report(),
|
|
HumanOutputOptions {
|
|
show_details: true,
|
|
show_all: false,
|
|
ascii: false,
|
|
color_enabled: false,
|
|
},
|
|
);
|
|
assert!(rendered.contains(" OPENAI_API_KEY present"));
|
|
}
|
|
|
|
#[test]
|
|
fn render_human_report_explains_terminal_warning_issue() {
|
|
let report = DoctorReport {
|
|
schema_version: 1,
|
|
generated_at: "0s since unix epoch".to_string(),
|
|
overall_status: CheckStatus::Warning,
|
|
codex_version: "0.0.0".to_string(),
|
|
checks: vec![
|
|
DoctorCheck::new(
|
|
"terminal.env",
|
|
"terminal",
|
|
CheckStatus::Warning,
|
|
"width 79 cols - output may wrap (recommended >=80)",
|
|
)
|
|
.detail("terminal: Ghostty")
|
|
.detail("terminal version: 1.3.1")
|
|
.detail("terminal size: 79x26")
|
|
.issue(
|
|
super::super::DoctorIssue::new(
|
|
CheckStatus::Warning,
|
|
"width 79 cols - output may wrap (recommended >=80)",
|
|
)
|
|
.expected(">= 80 columns")
|
|
.remedy("resize the window to at least 80 columns")
|
|
.field("terminal size"),
|
|
),
|
|
],
|
|
};
|
|
|
|
let rendered = render_human_report(&report, detailed_no_color_unicode_options());
|
|
|
|
assert!(
|
|
rendered.contains("⚠ terminal width 79 cols - output may wrap (recommended >=80)")
|
|
);
|
|
assert!(rendered.contains("▸ terminal size 79x26 (expected >= 80 columns)"));
|
|
assert!(rendered.contains("→ resize the window to at least 80 columns"));
|
|
assert!(!rendered.contains("⚠ terminal Ghostty 1.3.1"));
|
|
}
|
|
|
|
#[test]
|
|
fn render_human_report_promotes_notes_without_changing_statuses() {
|
|
let report = DoctorReport {
|
|
schema_version: 1,
|
|
generated_at: "0s since unix epoch".to_string(),
|
|
overall_status: CheckStatus::Warning,
|
|
codex_version: "0.0.0".to_string(),
|
|
checks: vec![
|
|
DoctorCheck::new(
|
|
"updates.status",
|
|
"updates",
|
|
CheckStatus::Ok,
|
|
"update configuration is locally consistent",
|
|
)
|
|
.detail("latest version status: newer version is available")
|
|
.detail("latest version: 0.130.0")
|
|
.detail("dismissed version: 0.128.0"),
|
|
DoctorCheck::new(
|
|
"state.paths",
|
|
"state",
|
|
CheckStatus::Ok,
|
|
"state paths inspectable",
|
|
)
|
|
.detail("active rollout files: 1515 files, 2702146365 total bytes, 1783594 average bytes"),
|
|
DoctorCheck::new(
|
|
"sandbox.helpers",
|
|
"sandbox",
|
|
CheckStatus::Ok,
|
|
"sandbox configuration is readable",
|
|
)
|
|
.detail("filesystem sandbox: danger-full-access")
|
|
.detail("network sandbox: restricted")
|
|
.detail("approval policy: Never"),
|
|
DoctorCheck::new(
|
|
"mcp.config",
|
|
"mcp",
|
|
CheckStatus::Warning,
|
|
"MCP configuration has optional issues",
|
|
),
|
|
DoctorCheck::new(
|
|
"network.websocket_reachability",
|
|
"websocket",
|
|
CheckStatus::Ok,
|
|
"Responses WebSocket handshake succeeded",
|
|
)
|
|
.detail("auth mode: chatgpt"),
|
|
DoctorCheck::new(
|
|
"network.provider_reachability",
|
|
"reachability",
|
|
CheckStatus::Ok,
|
|
"active provider endpoints are reachable over HTTP",
|
|
)
|
|
.detail("reachability mode: API key auth"),
|
|
DoctorCheck::new(
|
|
"app_server.status",
|
|
"app-server",
|
|
CheckStatus::Ok,
|
|
"background server is not running",
|
|
)
|
|
.detail("status: not running")
|
|
.detail("mode: ephemeral"),
|
|
],
|
|
};
|
|
|
|
let rendered = render_human_report(&report, summary_no_color_unicode_options());
|
|
|
|
assert!(rendered.contains("Notes\n ↑ updates"));
|
|
assert!(rendered.contains("0.130.0 available (current 0.0.0, dismissed 0.128.0)"));
|
|
assert!(rendered.contains("⚠ rollouts"));
|
|
assert!(rendered.contains("⚠ sandbox"));
|
|
assert!(rendered.contains("⚠ mcp"));
|
|
assert!(rendered.contains(
|
|
"⚠ auth mixed auth signals: ChatGPT login plus API key env var; HTTP reachability uses API-key mode"
|
|
));
|
|
assert!(rendered.contains("○ app-server not running (ephemeral mode)"));
|
|
assert!(rendered.contains("5 ok · 1 idle · 5 notes · 1 warn · 0 fail degraded"));
|
|
}
|
|
|
|
#[test]
|
|
fn render_human_report_expands_feature_flags_with_all() {
|
|
let report = DoctorReport {
|
|
schema_version: 1,
|
|
generated_at: "0s since unix epoch".to_string(),
|
|
overall_status: CheckStatus::Ok,
|
|
codex_version: "0.0.0".to_string(),
|
|
checks: vec![
|
|
DoctorCheck::new("config.load", "config", CheckStatus::Ok, "config loaded")
|
|
.detail("model: gpt-5.5")
|
|
.detail("model provider: openai")
|
|
.detail("feature flags enabled: 3")
|
|
.detail("enabled feature flags: shell_tool, memories, goals")
|
|
.detail("feature flag overrides: memories=true"),
|
|
],
|
|
};
|
|
|
|
let compact = render_human_report(&report, detailed_no_color_unicode_options());
|
|
let expanded = render_human_report(&report, detailed_all_no_color_unicode_options());
|
|
|
|
assert!(!compact.contains("enabled flags"));
|
|
assert!(
|
|
compact.contains(
|
|
"feature flags 3 enabled · 1 overridden (full list with --all)"
|
|
)
|
|
);
|
|
assert!(expanded.contains("enabled flags shell_tool, memories, goals"));
|
|
}
|
|
|
|
#[test]
|
|
fn detail_value_colors_inline_statuses_and_low_signal_values() {
|
|
let rendered = detail_value(
|
|
"npm: no · commit unknown · integrity ok · ~/code/codex/target/debug/codex · <redacted>",
|
|
detailed_color_unicode_options(),
|
|
);
|
|
|
|
assert!(rendered.contains("npm: \u{1b}[38;5;240mno"));
|
|
assert!(rendered.contains("\u{1b}[38;5;240munknown"));
|
|
assert!(rendered.contains("\u{1b}[38;5;10mok"));
|
|
assert!(rendered.contains("\u{1b}[38;5;117m~/code/codex/target/debug/codex"));
|
|
assert!(rendered.contains("\u{1b}[38;5;244m"));
|
|
}
|
|
|
|
#[test]
|
|
fn update_note_emphasizes_available_version_and_dims_context() {
|
|
let rendered = style_update_note_summary(
|
|
"0.130.0 available (current 0.0.0, dismissed 0.128.0)",
|
|
detailed_color_unicode_options(),
|
|
);
|
|
|
|
assert!(rendered.contains("\u{1b}[38;5;220m0.130.0 available"));
|
|
assert!(rendered.contains("\u{1b}[2m(current 0.0.0, dismissed 0.128.0)"));
|
|
}
|
|
|
|
#[test]
|
|
fn redact_detail_sanitizes_urls() {
|
|
let redacted = redact_detail(
|
|
"reachability failed: https://user:pass@example.com/mcp?x=abc#frag (connect failed)",
|
|
);
|
|
|
|
assert_eq!(
|
|
redacted,
|
|
"reachability failed: https://example.com/mcp (connect failed)"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn redact_detail_sanitizes_secret_url_path_segments() {
|
|
let redacted = redact_detail("reachability failed: https://example.com/mcp/abc123xyz");
|
|
|
|
assert_eq!(
|
|
redacted,
|
|
"reachability failed: https://example.com/mcp/<redacted>"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn redact_detail_preserves_env_var_names() {
|
|
assert_eq!(
|
|
redact_detail("auth env vars present: OPENAI_API_KEY, CODEX_API_KEY"),
|
|
"auth env vars present: OPENAI_API_KEY, CODEX_API_KEY"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn redact_detail_preserves_secret_presence_booleans() {
|
|
assert_eq!(
|
|
redact_detail("stored ChatGPT tokens: true"),
|
|
"stored ChatGPT tokens: true"
|
|
);
|
|
assert_eq!(
|
|
redact_detail("stored ChatGPT tokens: false"),
|
|
"stored ChatGPT tokens: false"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn render_human_report_can_emit_color() {
|
|
let rendered = render_human_report(
|
|
&sample_report(),
|
|
HumanOutputOptions {
|
|
show_details: false,
|
|
show_all: false,
|
|
ascii: false,
|
|
color_enabled: true,
|
|
},
|
|
);
|
|
assert!(rendered.contains("\u{1b}["));
|
|
}
|
|
}
|