//! Converts raw doctor detail strings into human-oriented rows.//!//! Checks intentionally store details as simple redacted `label: value` strings//! so JSON serialization and human rendering share the same source data. This//! module owns the presentation-only transformations: collapsing noisy booleans,//! truncating long paths for terminal output, grouping repeated values, and//! keeping the `--all` expansion behavior out of check construction.use std::collections::BTreeSet;use std::env;use super::DoctorCheck;use super::HumanOutputOptions;use super::redact_detail;const LIST_LIMIT: usize = 7;const PATH_LIMIT: usize = 48;#[derive(Clone, Debug, Eq, PartialEq)]pub(super) enum HumanDetail { Row { label: String, value: String, expected: Option<String>, }, Continuation(String), Bullet(String), Remedy(String),}#[derive(Clone, Debug, Eq, PartialEq)]struct ParsedDetail { label: String, value: String,}pub(super) fn detail_lines(check: &DoctorCheck, options: HumanOutputOptions) -> Vec<HumanDetail> { let parsed = parsed_details(check); let details = match check.category.as_str() { "system" => system_details(&parsed), "runtime" => runtime_details(&parsed), "install" => install_details(&parsed, options), "git" => git_details(&parsed, options), "title" => title_details(&parsed), "config" => config_details(&parsed, options), "state" => state_details(&parsed), _ => generic_details(&parsed), }; let mut details = details .into_iter() .map(|detail| attach_issue_metadata(detail, check)) .map(|detail| humanize_detail(detail, options)) .collect::<Vec<_>>(); details.extend(issue_remedies(check)); details}fn system_details(parsed: &[ParsedDetail]) -> Vec<HumanDetail> { let mut out = Vec::new(); push_row_if_present(&mut out, parsed, "os", "os"); push_row_if_present(&mut out, parsed, "os language", "OS language"); push_row_if_present(&mut out, parsed, "LC_ALL", "LC_ALL"); push_row_if_present(&mut out, parsed, "LC_CTYPE", "LC_CTYPE"); push_row_if_present(&mut out, parsed, "LANG", "LANG"); push_row_if_present(&mut out, parsed, "VISUAL", "VISUAL"); push_row_if_present(&mut out, parsed, "EDITOR", "EDITOR"); push_row_if_present(&mut out, parsed, "PAGER", "PAGER"); push_row_if_present(&mut out, parsed, "GIT_PAGER", "GIT_PAGER"); push_row_if_present(&mut out, parsed, "GH_PAGER", "GH_PAGER"); push_row_if_present(&mut out, parsed, "LESS", "LESS"); push_remaining( &mut out, parsed, &[ "os", "os type", "os version", "os language", "LC_ALL", "LC_CTYPE", "LANG", "VISUAL", "EDITOR", "PAGER", "GIT_PAGER", "GH_PAGER", "LESS", ], &[], ); out}pub(super) fn detail_value(check: &DoctorCheck, label: &str) -> Option<String> { parsed_details(check) .into_iter() .find(|detail| detail.label == label) .map(|detail| detail.value)}pub(super) fn rollout_summary(value: &str) -> Option<String> { let (files, rest) = value.split_once(" files, ")?; let (total_bytes, rest) = rest.split_once(" total bytes, ")?; let (average_bytes, _) = rest.split_once(" average bytes")?; let files = files.trim().parse::<u64>().ok()?; let total_bytes = total_bytes.trim().parse::<u64>().ok()?; let average_bytes = average_bytes.trim().parse::<u64>().ok()?; Some(format!( "{} files · {} (avg {})", format_count(files), format_bytes(total_bytes), format_bytes(average_bytes) ))}pub(super) fn rollout_files_and_bytes(value: &str) -> Option<(u64, u64)> { let (files, rest) = value.split_once(" files, ")?; let (total_bytes, _) = rest.split_once(" total bytes, ")?; Some(( files.trim().parse::<u64>().ok()?, total_bytes.trim().parse::<u64>().ok()?, ))}pub(super) fn format_bytes(bytes: u64) -> String { const KIB: f64 = 1024.0; const MIB: f64 = KIB * 1024.0; const GIB: f64 = MIB * 1024.0; let bytes = bytes as f64; if bytes >= GIB { format!("{:.2} GB", bytes / GIB) } else if bytes >= MIB { format!("{:.2} MB", bytes / MIB) } else if bytes >= KIB { format!("{:.2} KB", bytes / KIB) } else { format!("{} B", bytes as u64) }}pub(super) fn format_count(count: u64) -> String { let mut digits = count.to_string(); let mut out = String::new(); while digits.len() > 3 { let tail = digits.split_off(digits.len() - 3); if out.is_empty() { out = tail; } else { out = format!("{tail},{out}"); } } if out.is_empty() { digits } else { format!("{digits},{out}") }}fn parsed_details(check: &DoctorCheck) -> Vec<ParsedDetail> { check .details .iter() .map(|detail| redact_detail(detail)) .map(|detail| { detail .split_once(": ") .map(|(label, value)| ParsedDetail { label: label.to_string(), value: value.to_string(), }) .unwrap_or_else(|| ParsedDetail { label: String::new(), value: detail, }) }) .collect()}fn runtime_details(parsed: &[ParsedDetail]) -> Vec<HumanDetail> { let mut out = Vec::new(); push_row_if_present(&mut out, parsed, "version", "version"); push_row_if_present(&mut out, parsed, "install method", "install method"); push_row_if_present(&mut out, parsed, "commit", "commit"); push_row_if_present(&mut out, parsed, "current executable", "executable"); push_remaining( &mut out, parsed, &[ "version", "platform", "install method", "commit", "current executable", ], &[], ); out}fn install_details(parsed: &[ParsedDetail], options: HumanOutputOptions) -> Vec<HumanDetail> { let mut out = Vec::new(); push_row_if_present(&mut out, parsed, "install context", "context"); if parsed.iter().any(|detail| { detail.value == "ignored inherited package-manager launch env for cargo-built binary" }) { out.push(HumanDetail::Bullet( "ignored inherited package-manager launch env for cargo-built binary".to_string(), )); } let managed_by_npm = value(parsed, "managed by npm").unwrap_or("false"); let managed_by_bun = value(parsed, "managed by bun").unwrap_or("false"); let package_root = value(parsed, "managed package root").unwrap_or("not set"); out.push(HumanDetail::Row { label: "managed by".to_string(), value: format!( "npm: {} · bun: {} · package root {}", yes_no(managed_by_npm), yes_no(managed_by_bun), if is_falsy(package_root) { "—".to_string() } else { package_root.to_string() } ), expected: None, }); let path_entries = numbered_values(parsed, "PATH codex #"); if !path_entries.is_empty() { let total = path_entries.len(); let shown = if options.show_all { total } else { total.min(3) }; out.push(HumanDetail::Row { label: format!("PATH entries ({total})"), value: path_entries[0].clone(), expected: None, }); out.extend( path_entries .iter() .skip(1) .take(shown.saturating_sub(1)) .cloned() .map(HumanDetail::Continuation), ); if shown < total { out.push(HumanDetail::Continuation( "… (full list with --all)".to_string(), )); } } push_remaining( &mut out, parsed, &[ "current executable", "install context", "managed by npm", "managed by bun", "managed package root", "PATH codex entries", ], &["PATH codex #"], ); out}fn git_details(parsed: &[ParsedDetail], options: HumanOutputOptions) -> Vec<HumanDetail> { let mut out = Vec::new(); push_row_if_present(&mut out, parsed, "selected git", "selected git"); push_row_if_present(&mut out, parsed, "git version", "version"); push_row_if_present(&mut out, parsed, "git exec path", "exec path"); push_row_if_present(&mut out, parsed, "repo detected", "repo detected"); push_row_if_present(&mut out, parsed, "repo root", "repo root"); push_row_if_present(&mut out, parsed, ".git entry", ".git entry"); push_row_if_present(&mut out, parsed, "git branch", "branch"); push_row_if_present(&mut out, parsed, "core.fsmonitor", "core.fsmonitor"); let path_entries = numbered_values(parsed, "PATH git #"); if !path_entries.is_empty() { let total = path_entries.len(); let shown = if options.show_all { total } else { total.min(3) }; out.push(HumanDetail::Row { label: format!("PATH entries ({total})"), value: path_entries[0].clone(), expected: None, }); out.extend( path_entries .iter() .skip(1) .take(shown.saturating_sub(1)) .cloned() .map(HumanDetail::Continuation), ); if shown < total { out.push(HumanDetail::Continuation( "… (full list with --all)".to_string(), )); } } push_remaining( &mut out, parsed, &[ "selected git", "PATH git entries", "git version", "git exec path", "git build options", "repo detected", "repo root", ".git entry", "git branch", "core.fsmonitor", ], &["PATH git #"], ); out}fn title_details(parsed: &[ParsedDetail]) -> Vec<HumanDetail> { let mut out = Vec::new(); push_row_if_present(&mut out, parsed, "terminal title source", "title source"); push_row_if_present(&mut out, parsed, "terminal title items", "title items"); push_row_if_present(&mut out, parsed, "terminal title activity", "activity item"); push_row_if_present( &mut out, parsed, "terminal title project source", "project source", ); push_row_if_present( &mut out, parsed, "terminal title project value", "project value", ); push_remaining( &mut out, parsed, &[ "terminal title source", "terminal title items", "terminal title activity", "terminal title project source", "terminal title project value", ], &[], ); out}fn config_details(parsed: &[ParsedDetail], options: HumanOutputOptions) -> Vec<HumanDetail> { let mut out = Vec::new(); if let Some(model) = value(parsed, "model") { let value = value(parsed, "model provider").map_or_else( || model.to_string(), |provider| format!("{model} · {provider}"), ); out.push(HumanDetail::Row { label: "model".to_string(), value, expected: None, }); } push_row_if_present(&mut out, parsed, "cwd", "cwd"); push_row_if_present(&mut out, parsed, "config.toml", "config.toml"); push_row_if_present(&mut out, parsed, "config.toml parse", "config.toml parse"); push_row_if_present(&mut out, parsed, "config.toml read", "config.toml read"); push_row_if_present(&mut out, parsed, "mcp servers", "MCP servers"); push_feature_flags(&mut out, parsed, options); for detail in parsed .iter() .filter(|detail| detail.label == "legacy feature flag") { out.push(HumanDetail::Row { label: "legacy alias".to_string(), value: detail.value.clone(), expected: None, }); } push_remaining( &mut out, parsed, &[ "CODEX_HOME", "cwd", "model", "model provider", "log dir", "sqlite home", "mcp servers", "feature flags enabled", "enabled feature flags", "feature flag overrides", "legacy feature flag", "config.toml", "config.toml parse", "config.toml read", ], &[], ); out}fn state_details(parsed: &[ParsedDetail]) -> Vec<HumanDetail> { let mut out = Vec::new(); push_row_if_present(&mut out, parsed, "CODEX_HOME", "CODEX_HOME"); push_row_if_present(&mut out, parsed, "log dir", "log dir"); push_row_if_present(&mut out, parsed, "sqlite home", "sqlite home"); push_database_row(&mut out, parsed, "state DB"); push_database_row(&mut out, parsed, "log DB"); push_database_row(&mut out, parsed, "goals DB"); push_database_row(&mut out, parsed, "memories DB"); for (source, label) in [ ("active rollout files", "active rollouts"), ("archived rollout files", "archived rollouts"), ] { if let Some(value) = value(parsed, source) { out.push(HumanDetail::Row { label: label.to_string(), value: rollout_summary(value).unwrap_or_else(|| value.to_string()), expected: None, }); } } push_remaining( &mut out, parsed, &[ "CODEX_HOME", "log dir", "sqlite home", "state DB", "log DB", "goals DB", "state DB integrity", "log DB integrity", "goals DB integrity", "memories DB", "memories DB integrity", "active rollout files", "archived rollout files", ], &[], ); out}fn generic_details(parsed: &[ParsedDetail]) -> Vec<HumanDetail> { parsed .iter() .map(|detail| { if detail.label.is_empty() { HumanDetail::Bullet(detail.value.clone()) } else { HumanDetail::Row { label: display_label(&detail.label), value: detail.value.clone(), expected: None, } } }) .collect()}fn push_feature_flags( out: &mut Vec<HumanDetail>, parsed: &[ParsedDetail], options: HumanOutputOptions,) { let enabled_count = value(parsed, "feature flags enabled") .and_then(|value| value.parse::<usize>().ok()) .unwrap_or_default(); let overrides = list_items(value(parsed, "feature flag overrides").unwrap_or("none")); let override_count = overrides.len(); let hint = if !options.show_all && enabled_count > 0 { " (full list with --all)" } else { "" }; out.push(HumanDetail::Row { label: "feature flags".to_string(), value: format!("{enabled_count} enabled · {override_count} overridden{hint}"), expected: None, }); if !overrides.is_empty() { push_list_row(out, "overrides", &override_names(&overrides), options); } if options.show_all { let enabled = list_items(value(parsed, "enabled feature flags").unwrap_or("none")); if !enabled.is_empty() { push_list_row(out, "enabled flags", &enabled, options); } }}fn push_list_row( out: &mut Vec<HumanDetail>, label: &str, items: &[String], options: HumanOutputOptions,) { let limit = if options.show_all { items.len() } else { items.len().min(LIST_LIMIT) }; let mut value = items .iter() .take(limit) .cloned() .collect::<Vec<_>>() .join(", "); if limit < items.len() { value.push_str(", … (full list with --all)"); } out.push(HumanDetail::Row { label: label.to_string(), value, expected: None, });}fn push_database_row(out: &mut Vec<HumanDetail>, parsed: &[ParsedDetail], label: &str) { let Some(path) = value(parsed, label) else { return; }; let integrity = value(parsed, &format!("{label} integrity")); let value = integrity.map_or_else( || path.to_string(), |integrity| format!("{path} · integrity {integrity}"), ); out.push(HumanDetail::Row { label: label.to_string(), value, expected: None, });}fn push_row_if_present( out: &mut Vec<HumanDetail>, parsed: &[ParsedDetail], source_label: &str, display_label: &str,) { if let Some(value) = value(parsed, source_label) { out.push(HumanDetail::Row { label: display_label.to_string(), value: value.to_string(), expected: None, }); }}fn push_remaining( out: &mut Vec<HumanDetail>, parsed: &[ParsedDetail], consumed_labels: &[&str], consumed_prefixes: &[&str],) { for detail in parsed { if detail.value == "ignored inherited package-manager launch env for cargo-built binary" { continue; } if consumed_labels.contains(&detail.label.as_str()) || consumed_prefixes .iter() .any(|prefix| detail.label.starts_with(prefix)) { continue; } if detail.label.is_empty() { out.push(HumanDetail::Bullet(detail.value.clone())); } else { out.push(HumanDetail::Row { label: display_label(&detail.label), value: detail.value.clone(), expected: None, }); } }}fn humanize_detail(detail: HumanDetail, options: HumanOutputOptions) -> HumanDetail { match detail { HumanDetail::Row { label, value, expected, } => HumanDetail::Row { label, value: humanize_value(&value, options), expected, }, HumanDetail::Continuation(value) => { HumanDetail::Continuation(humanize_value(&value, options)) } HumanDetail::Bullet(value) => HumanDetail::Bullet(humanize_value(&value, options)), HumanDetail::Remedy(value) => HumanDetail::Remedy(value), }}fn attach_issue_metadata(detail: HumanDetail, check: &DoctorCheck) -> HumanDetail { let HumanDetail::Row { label, value, expected, } = detail else { return detail; }; let expected = expected.or_else(|| issue_expected_for_label(check, &label)); HumanDetail::Row { label, value, expected, }}fn issue_expected_for_label(check: &DoctorCheck, label: &str) -> Option<String> { check .issues .iter() .find(|issue| { issue .fields .iter() .any(|field| display_label(field) == label || field == label) }) .and_then(|issue| issue.expected.clone())}fn issue_remedies(check: &DoctorCheck) -> Vec<HumanDetail> { let mut seen = BTreeSet::new(); check .issues .iter() .filter_map(|issue| issue.remedy.as_ref()) .filter(|remedy| seen.insert((*remedy).clone())) .cloned() .map(HumanDetail::Remedy) .collect()}fn humanize_value(value: &str, _options: HumanOutputOptions) -> String { if looks_like_path(value) { return shorten_path_prefix(value); } if let Some(timestamp) = humanize_timestamp(value) { return timestamp; } value.to_string()}fn humanize_timestamp(value: &str) -> Option<String> { if value.len() < 17 || !value.ends_with('Z') { return None; } let (date, time) = value.split_once('T')?; let hour_minute = time.get(..5)?; Some(format!("{date} {hour_minute} UTC"))}fn shorten_path_prefix(value: &str) -> String { let (path, suffix) = value.split_once(" (").map_or_else( || (value, String::new()), |(path, suffix)| (path, format!(" ({suffix}")), ); let home_shortened = home_shortened_path(path); let shortened = middle_truncate(&home_shortened, PATH_LIMIT); format!("{shortened}{suffix}")}fn home_shortened_path(path: &str) -> String { let Some(home) = env::var_os("HOME").and_then(|home| home.into_string().ok()) else { return path.to_string(); }; if path == home { "~".to_string() } else { path.strip_prefix(&format!("{home}/")) .map_or_else(|| path.to_string(), |tail| format!("~/{tail}")) }}fn middle_truncate(value: &str, max_chars: usize) -> String { let char_count = value.chars().count(); if char_count <= max_chars { return value.to_string(); } let head_len = max_chars / 2; let tail_len = max_chars.saturating_sub(head_len + 1); let head = value.chars().take(head_len).collect::<String>(); let tail = value .chars() .rev() .take(tail_len) .collect::<String>() .chars() .rev() .collect::<String>(); format!("{head}…{tail}")}fn looks_like_path(value: &str) -> bool { value.starts_with('/') || value.starts_with("~/") || value.starts_with("./") || value.starts_with("../")}fn numbered_values(parsed: &[ParsedDetail], prefix: &str) -> Vec<String> { parsed .iter() .filter(|detail| detail.label.starts_with(prefix)) .map(|detail| detail.value.clone()) .collect()}fn value<'a>(parsed: &'a [ParsedDetail], label: &str) -> Option<&'a str> { parsed .iter() .find(|detail| detail.label == label) .map(|detail| detail.value.as_str())}fn display_label(label: &str) -> String { match label { "codex-linux-sandbox helper" => "linux helper", "optional reachability failed" => "optional reachability", "check for update on startup" => "startup update check", other => other, } .to_string()}fn list_items(value: &str) -> Vec<String> { if is_falsy(value) { return Vec::new(); } value .split(',') .map(str::trim) .filter(|item| !item.is_empty()) .map(str::to_string) .collect()}fn override_names(items: &[String]) -> Vec<String> { items .iter() .map(|item| item.split_once('=').map_or(item.as_str(), |(name, _)| name)) .map(str::to_string) .collect()}fn yes_no(value: &str) -> &'static str { if value == "true" { "yes" } else { "no" }}pub(super) fn is_falsy(value: &str) -> bool { matches!( value.trim().to_ascii_lowercase().as_str(), "" | "false" | "none" | "not set" | "unknown" | "missing" | "absent" | "no" | "—" | "-" )}