use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use super::{
CacheRootState, ClaudeDirState, ClaudeHomeState, ClaudeJsonState, ClaudeSessionsState,
ConfigReadOutcome, CredentialErrorSummary, CredentialsSummary, DoctorCacheSnapshot,
DoctorConfigSnapshot, DoctorCredentialsSnapshot, DoctorEndpointSnapshot, DoctorUpdateProbe,
EndpointProbe, EndpointProbeOutcome, EnvVarState, LockState, PluginDirState, PluginDirStatus,
UsageJsonState,
};
use crate::config::ConfigPath;
use crate::data_context::credentials::FileCascadeEnv;
pub(super) fn snapshot_plugins(
read: &ConfigReadOutcome,
xdg_env: &crate::data_context::xdg::XdgEnv,
) -> (BTreeSet<String>, super::DoctorPluginsSnapshot) {
use super::{DoctorPluginsSnapshot, PluginsRegistrySummary};
let mut ids = DoctorConfigSnapshot::built_in_segment_ids();
let cfg = match read {
ConfigReadOutcome::Loaded { config, .. } => Some(config.as_ref()),
_ => None,
};
let Some((registry, _engine)) = crate::runtime::plugins::load_plugins(cfg, xdg_env) else {
return (ids, DoctorPluginsSnapshot::NoSources);
};
let mut compiled_count = 0usize;
for plugin in registry.iter() {
ids.insert(plugin.id().to_string());
compiled_count += 1;
}
(
ids,
DoctorPluginsSnapshot::Discovered(PluginsRegistrySummary {
compiled_count,
errors: registry.load_errors().to_vec(),
}),
)
}
pub(super) fn snapshot_git() -> super::DoctorGitSnapshot {
use super::{DoctorGitSnapshot, GitContextSummary};
let cwd = match std::env::current_dir() {
Ok(c) => c,
Err(e) => {
return DoctorGitSnapshot::Failed {
message: format!("cannot read current directory: {e}"),
};
}
};
match crate::data_context::git::resolve_repo(&cwd) {
Ok(None) => DoctorGitSnapshot::NotInRepo,
Ok(Some(ctx)) => DoctorGitSnapshot::Repo(GitContextSummary {
repo_path: ctx.repo_path,
repo_kind: ctx.repo_kind,
head: ctx.head,
}),
Err(e) => DoctorGitSnapshot::Failed {
message: e.to_string(),
},
}
}
pub(super) fn collect_known_theme_names(
xdg_env: &crate::data_context::xdg::XdgEnv,
) -> BTreeSet<String> {
let dir = crate::runtime::themes::user_themes_dir(xdg_env);
let registry = crate::runtime::themes::build_theme_registry(dir.as_deref(), |_| {});
let mut names = DoctorConfigSnapshot::built_in_theme_names();
for registered in registry.iter() {
names.insert(registered.theme.name().to_string());
}
names
}
pub(super) fn read_config_at(cp: &ConfigPath) -> ConfigReadOutcome {
use crate::runtime::config::{load_config, ConfigLoadOutcome};
match load_config(Some(cp)) {
ConfigLoadOutcome::Unresolved => ConfigReadOutcome::Unresolved,
ConfigLoadOutcome::Loaded { path, config, .. } => {
ConfigReadOutcome::Loaded { path, config }
}
ConfigLoadOutcome::NotFound { path, explicit } => {
ConfigReadOutcome::NotFound { path, explicit }
}
ConfigLoadOutcome::IoError { path, source, .. } => ConfigReadOutcome::IoError {
path,
message: source.to_string(),
},
ConfigLoadOutcome::ParseError { path, source, .. } => ConfigReadOutcome::ParseError {
path,
message: source.to_string(),
},
_ => ConfigReadOutcome::IoError {
path: cp.path.clone(),
message: "unrecognized config load outcome (cli/core version skew); upgrade `linesmith` to match the installed `linesmith-core`".to_string(),
},
}
}
pub(super) fn stat_plugin_dirs(paths: &[PathBuf]) -> Vec<PluginDirStatus> {
paths
.iter()
.map(|path| {
let state = match std::fs::metadata(path) {
Ok(meta) if meta.is_dir() => PluginDirState::Ok,
Ok(_) => PluginDirState::NotADirectory,
Err(e) => match e.kind() {
std::io::ErrorKind::NotFound => PluginDirState::Missing,
std::io::ErrorKind::PermissionDenied => PluginDirState::PermissionDenied {
message: e.to_string(),
},
_ => PluginDirState::OtherIo {
message: e.to_string(),
},
},
};
PluginDirStatus {
path: path.clone(),
state,
}
})
.collect()
}
pub(super) fn find_claude_binary(path_env: Option<&std::ffi::OsStr>) -> Option<PathBuf> {
let path = path_env?;
let candidates: &[&str] = if cfg!(windows) {
&["claude.exe", "claude.cmd", "claude.bat"]
} else {
&["claude"]
};
for dir in std::env::split_paths(path) {
if dir.as_os_str().is_empty() {
continue;
}
for exe_name in candidates {
let candidate = dir.join(exe_name);
if is_runnable(&candidate) {
return Some(candidate);
}
}
}
None
}
#[cfg(unix)]
fn is_runnable(path: &Path) -> bool {
use std::os::unix::fs::PermissionsExt;
std::fs::metadata(path).is_ok_and(|m| m.is_file() && m.permissions().mode() & 0o111 != 0)
}
#[cfg(not(unix))]
fn is_runnable(path: &Path) -> bool {
path.is_file()
}
pub(super) fn snapshot_claude_home(home: &Path) -> ClaudeHomeState {
let claude_dir = home.join(".claude");
let dir = stat_claude_dir(&claude_dir);
let claude_json = read_claude_json(&home.join(".claude.json"));
let sessions = stat_claude_sessions(&claude_dir.join("sessions"));
ClaudeHomeState {
dir,
claude_json,
sessions,
}
}
fn stat_claude_dir(path: &Path) -> ClaudeDirState {
match std::fs::metadata(path) {
Ok(meta) if meta.is_dir() => ClaudeDirState::Ok,
Ok(_) => ClaudeDirState::NotADirectory,
Err(e) => match e.kind() {
std::io::ErrorKind::NotFound => ClaudeDirState::Missing,
std::io::ErrorKind::PermissionDenied => ClaudeDirState::PermissionDenied {
message: e.to_string(),
},
_ => ClaudeDirState::OtherIo {
message: e.to_string(),
},
},
}
}
const CLAUDE_JSON_MAX_BYTES: u64 = 2 * 1024 * 1024;
pub(super) fn read_claude_json(path: &Path) -> ClaudeJsonState {
match std::fs::metadata(path) {
Ok(meta) if meta.len() > CLAUDE_JSON_MAX_BYTES => {
return ClaudeJsonState::TooLarge {
actual_bytes: meta.len(),
};
}
Ok(_) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return ClaudeJsonState::Missing,
Err(e) => {
return ClaudeJsonState::IoError {
message: e.to_string(),
};
}
}
let raw = match std::fs::read_to_string(path) {
Ok(s) => s,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return ClaudeJsonState::Missing,
Err(e) => {
return ClaudeJsonState::IoError {
message: e.to_string(),
};
}
};
match serde_json::from_str::<serde_json::Value>(&raw) {
Ok(value) => {
if value.get("oauthAccount").is_some() {
ClaudeJsonState::Ok
} else {
ClaudeJsonState::NoOauthAccount
}
}
Err(e) => ClaudeJsonState::ParseError {
message: e.to_string(),
},
}
}
pub(super) fn stat_claude_sessions(path: &Path) -> ClaudeSessionsState {
let entries = match std::fs::read_dir(path) {
Ok(it) => it,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return ClaudeSessionsState::Missing,
Err(e) => {
if std::fs::metadata(path).is_ok_and(|m| !m.is_dir()) {
return ClaudeSessionsState::NotADirectory;
}
return ClaudeSessionsState::IoError {
message: e.to_string(),
};
}
};
let mut count = 0usize;
for entry in entries {
let entry = match entry {
Ok(e) => e,
Err(e) => {
return ClaudeSessionsState::IoError {
message: e.to_string(),
};
}
};
let is_session_file = entry.file_type().is_ok_and(|t| t.is_file())
&& !entry.file_name().to_string_lossy().starts_with('.');
if is_session_file {
count += 1;
}
}
if count == 0 {
ClaudeSessionsState::Empty
} else {
ClaudeSessionsState::HasFiles { count }
}
}
pub(super) fn snapshot_credentials(env: &FileCascadeEnv) -> DoctorCredentialsSnapshot {
use crate::data_context::credentials::{resolve_credentials_with, CredentialError};
if !cfg!(target_os = "macos")
&& env.claude_config_dir.is_none()
&& env.xdg_config_home.is_none()
&& env.home.is_none()
{
return DoctorCredentialsSnapshot::Unresolvable;
}
match resolve_credentials_with(env) {
Ok(creds) => DoctorCredentialsSnapshot::Resolved(CredentialsSummary {
source: creds.source().clone(),
scopes: creds.scopes().to_vec(),
}),
Err(err) => DoctorCredentialsSnapshot::Failed(match err {
CredentialError::NoCredentials => CredentialErrorSummary::NoCredentials,
CredentialError::SubprocessFailed(e) => CredentialErrorSummary::SubprocessFailed {
message: e.to_string(),
},
CredentialError::IoError { path, cause } => CredentialErrorSummary::IoError {
path,
message: cause.to_string(),
},
CredentialError::ParseError { path, .. } => CredentialErrorSummary::ParseError { path },
CredentialError::MissingField { path } => CredentialErrorSummary::MissingField { path },
CredentialError::EmptyToken { path } => CredentialErrorSummary::EmptyToken { path },
other => CredentialErrorSummary::SubprocessFailed {
message: format!("unrecognized credential error ({})", other.code()),
},
}),
}
}
pub(super) fn snapshot_cache(
xdg_cache_home: &EnvVarState,
home_env: &EnvVarState,
) -> DoctorCacheSnapshot {
let root_path = derive_cache_root(xdg_cache_home, home_env);
let Some(root) = root_path.clone() else {
return DoctorCacheSnapshot {
root_path: None,
root: CacheRootState::Unresolved,
usage_json: UsageJsonState::Missing,
lock: LockState::Absent,
};
};
let root_state = stat_cache_root(&root);
let usage_json = stat_usage_json(&root.join("usage.json"));
let lock = stat_usage_lock(&root);
DoctorCacheSnapshot {
root_path: Some(root),
root: root_state,
usage_json,
lock,
}
}
fn derive_cache_root(xdg_cache_home: &EnvVarState, home_env: &EnvVarState) -> Option<PathBuf> {
use crate::data_context::xdg::{resolve_subdir, XdgEnv, XdgScope};
let env = XdgEnv::from_os_options(
xdg_cache_home.nonempty_os().map(std::ffi::OsString::from),
None,
home_env.nonempty_os().map(std::ffi::OsString::from),
);
resolve_subdir(&env, XdgScope::Cache, "")
}
pub(super) fn stat_cache_root(path: &Path) -> CacheRootState {
match std::fs::metadata(path) {
Ok(meta) if meta.is_dir() => CacheRootState::Exists,
Ok(_) => CacheRootState::NotADirectory,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => classify_absent_cache_root(path),
Err(e) if is_not_a_directory(&e) => classify_absent_cache_root(path),
Err(e) => CacheRootState::Unreadable {
message: e.to_string(),
},
}
}
fn is_not_a_directory(e: &std::io::Error) -> bool {
e.raw_os_error() == Some(20)
}
fn classify_absent_cache_root(path: &Path) -> CacheRootState {
let mut existing = path.parent();
let mut blocked_by_perm: Option<PathBuf> = None;
while let Some(dir) = existing {
match std::fs::metadata(dir) {
Ok(meta) if meta.is_dir() => {
let parent = if let Some(blocked) = blocked_by_perm {
blocked
} else if meta.permissions().readonly() {
dir.to_path_buf()
} else {
return CacheRootState::Absent;
};
return CacheRootState::AbsentParentReadOnly { parent };
}
Ok(_) => {
return CacheRootState::AbsentParentReadOnly {
parent: dir.to_path_buf(),
};
}
Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => {
if blocked_by_perm.is_none() {
blocked_by_perm = Some(dir.to_path_buf());
}
existing = dir.parent();
}
Err(_) => existing = dir.parent(),
}
}
CacheRootState::AbsentParentReadOnly {
parent: blocked_by_perm.unwrap_or_else(|| PathBuf::from("/")),
}
}
pub(super) fn stat_usage_json(path: &Path) -> UsageJsonState {
use crate::data_context::cache::{CachedUsage, CACHE_SCHEMA_VERSION};
let raw = match std::fs::read_to_string(path) {
Ok(s) => s,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return UsageJsonState::Missing,
Err(e) => {
return UsageJsonState::Unreadable {
message: e.to_string(),
};
}
};
let Ok(value) = serde_json::from_str::<serde_json::Value>(&raw) else {
return UsageJsonState::Unreadable {
message: "usage.json is not valid JSON".to_string(),
};
};
let schema_version = value
.get("schema_version")
.and_then(serde_json::Value::as_u64)
.map(|n| n as u32);
if !matches!(schema_version, Some(v) if v == CACHE_SCHEMA_VERSION) {
return match schema_version {
Some(v) => UsageJsonState::Stale { schema_version: v },
None => UsageJsonState::Unreadable {
message: "usage.json missing `schema_version`".to_string(),
},
};
}
match serde_json::from_value::<CachedUsage>(value) {
Ok(entry) if entry.cached_at <= jiff::Timestamp::now() => UsageJsonState::Current {
schema_version: entry.schema_version,
},
Ok(_) => UsageJsonState::FutureTimestamp,
Err(e) => UsageJsonState::Unreadable {
message: format!("usage.json shape mismatch: {e}"),
},
}
}
pub(super) fn stat_usage_lock(cache_root: &Path) -> LockState {
let store = crate::data_context::cache::LockStore::new(cache_root.to_path_buf());
match store.read() {
Ok(None) => LockState::Absent,
Ok(Some(lock)) => {
let now = jiff::Timestamp::now().as_second();
if lock.blocked_until > now {
LockState::Active {
blocked_until_secs: lock.blocked_until,
}
} else {
LockState::Stale {
blocked_until_secs: lock.blocked_until,
}
}
}
Err(e) => LockState::Unreadable {
message: e.to_string(),
},
}
}
pub(super) const DOCTOR_SLOW_THRESHOLD: std::time::Duration = std::time::Duration::from_secs(2);
pub(super) fn probe_endpoint_via_ureq() -> DoctorEndpointSnapshot {
use crate::data_context::credentials::resolve_credentials;
use crate::data_context::fetcher::{UreqTransport, UsageTransport, DEFAULT_TIMEOUT};
use std::time::Instant;
let Ok(creds) = resolve_credentials() else {
return DoctorEndpointSnapshot {
probe: None,
credentials_vanished: true,
};
};
let transport = UreqTransport::new();
let start = Instant::now();
let url = format!(
"{}{}",
crate::data_context::cascade::DEFAULT_API_BASE_URL,
crate::data_context::fetcher::OAUTH_USAGE_PATH,
);
let result = transport.get(&url, creds.token(), DEFAULT_TIMEOUT);
let elapsed = start.elapsed();
let outcome = classify_endpoint_response(result, elapsed);
DoctorEndpointSnapshot {
probe: Some(EndpointProbe {
elapsed_ms: elapsed.as_millis(),
outcome,
}),
credentials_vanished: false,
}
}
pub(super) fn classify_endpoint_response(
result: std::io::Result<crate::data_context::fetcher::HttpResponse>,
elapsed: std::time::Duration,
) -> EndpointProbeOutcome {
use crate::data_context::usage::UsageApiResponse;
let resp = match result {
Ok(r) => r,
Err(_) => return EndpointProbeOutcome::TransportError,
};
match resp.status {
200..=299 => match serde_json::from_slice::<serde_json::Value>(&resp.body) {
Ok(value) => {
if serde_json::from_value::<UsageApiResponse>(value.clone()).is_err() {
return EndpointProbeOutcome::ParseError;
}
let extra_keys = collect_unexpected_endpoint_keys(&value);
if !extra_keys.is_empty() {
return EndpointProbeOutcome::UnexpectedShape { extra_keys };
}
if elapsed >= DOCTOR_SLOW_THRESHOLD {
EndpointProbeOutcome::Slow
} else {
EndpointProbeOutcome::Ok
}
}
Err(_) => EndpointProbeOutcome::TransportError,
},
429 => {
let retry_after_secs = resp
.retry_after
.as_deref()
.and_then(|s| s.trim().parse::<u64>().ok());
EndpointProbeOutcome::RateLimited { retry_after_secs }
}
400..=499 => EndpointProbeOutcome::BadStatus {
status: resp.status,
},
_ => EndpointProbeOutcome::TransportError,
}
}
fn collect_unexpected_endpoint_keys(value: &serde_json::Value) -> Vec<String> {
use crate::data_context::usage::{KNOWN_BUCKETS, RESEARCH_DOCUMENTED_BUCKETS};
let Some(obj) = value.as_object() else {
return Vec::new();
};
obj.keys()
.filter(|k| {
!KNOWN_BUCKETS.contains(&k.as_str())
&& !RESEARCH_DOCUMENTED_BUCKETS.contains(&k.as_str())
})
.cloned()
.collect()
}
const GITHUB_RELEASES_LATEST_URL: &str =
"https://api.github.com/repos/oakoss/linesmith/releases/latest";
const UPDATE_PROBE_TIMEOUT_SECS: u64 = 2;
const UPDATE_PROBE_MAX_BYTES: u64 = 256 * 1024;
pub(super) fn snapshot_update_probe() -> DoctorUpdateProbe {
use std::io::Read;
use std::time::Duration;
let mut builder = ureq::Agent::config_builder().http_status_as_error(false);
if let Some(proxy) = ureq::Proxy::try_from_env() {
builder = builder.proxy(Some(proxy));
}
let agent = ureq::Agent::new_with_config(builder.build());
let user_agent = format!("linesmith/{}", env!("CARGO_PKG_VERSION"));
let result = agent
.get(GITHUB_RELEASES_LATEST_URL)
.config()
.timeout_global(Some(Duration::from_secs(UPDATE_PROBE_TIMEOUT_SECS)))
.build()
.header("User-Agent", &user_agent)
.header("Accept", "application/vnd.github+json")
.call();
let mut response = match result {
Ok(r) => r,
Err(e) => {
return DoctorUpdateProbe::TransportError {
message: clamp_diag(&e.to_string()),
};
}
};
let status = response.status().as_u16();
if !(200..=299).contains(&status) {
return DoctorUpdateProbe::TransportError {
message: format!("HTTP {status}"),
};
}
let mut body = Vec::new();
if let Err(e) = response
.body_mut()
.as_reader()
.take(UPDATE_PROBE_MAX_BYTES + 1)
.read_to_end(&mut body)
{
return DoctorUpdateProbe::TransportError {
message: clamp_diag(&e.to_string()),
};
}
let truncated = body.len() as u64 > UPDATE_PROBE_MAX_BYTES;
classify_update_response_inner(&body, env!("CARGO_PKG_VERSION"), truncated)
}
fn clamp_diag(s: &str) -> String {
let first_line = s.lines().next().unwrap_or("");
let mut out: String = first_line.chars().take(200).collect();
if first_line.chars().count() > 200 || s.lines().count() > 1 {
out.push_str("...");
}
out
}
#[cfg(test)]
pub(super) fn classify_update_response(body: &[u8], current_version: &str) -> DoctorUpdateProbe {
classify_update_response_inner(body, current_version, false)
}
pub(super) fn classify_update_response_inner(
body: &[u8],
current_version: &str,
truncated: bool,
) -> DoctorUpdateProbe {
debug_assert!(
!truncated || body.len() as u64 > UPDATE_PROBE_MAX_BYTES,
"truncated=true requires body.len() ({}) > UPDATE_PROBE_MAX_BYTES ({})",
body.len(),
UPDATE_PROBE_MAX_BYTES,
);
if truncated {
return DoctorUpdateProbe::TransportError {
message: format!(
"response body exceeded {UPDATE_PROBE_MAX_BYTES} bytes; bump UPDATE_PROBE_MAX_BYTES"
),
};
}
let value: serde_json::Value = match serde_json::from_slice(body) {
Ok(v) => v,
Err(e) => {
return DoctorUpdateProbe::ParseError {
message: clamp_diag(&e.to_string()),
}
}
};
let Some(tag_name) = value.get("tag_name").and_then(|v| v.as_str()) else {
return DoctorUpdateProbe::ParseError {
message: "response missing `tag_name` field".to_string(),
};
};
let safe_tag = sanitize_tag(tag_name);
let local = parse_three_part_version(current_version);
let remote = parse_three_part_version(tag_name);
match (remote, local) {
(Some(r), Some(l)) if r > l => DoctorUpdateProbe::Newer { latest: safe_tag },
(Some(_), Some(_)) => DoctorUpdateProbe::Latest,
(Some(_), None) => DoctorUpdateProbe::ParseError {
message: format!("local version {current_version} unparseable as MAJOR.MINOR.PATCH"),
},
(None, Some(_)) => DoctorUpdateProbe::ParseError {
message: format!("remote tag {safe_tag} unparseable as MAJOR.MINOR.PATCH"),
},
(None, None) => {
if tag_name == current_version || tag_name.strip_prefix('v') == Some(current_version) {
DoctorUpdateProbe::Latest
} else {
DoctorUpdateProbe::Newer { latest: safe_tag }
}
}
}
}
fn sanitize_tag(s: &str) -> String {
s.chars().filter(|c| !c.is_control()).take(64).collect()
}
fn parse_three_part_version(s: &str) -> Option<(u32, u32, u32)> {
let s = s.strip_prefix('v').unwrap_or(s);
let mut parts = s.splitn(3, '.');
let major: u32 = parts.next()?.parse().ok()?;
let minor: u32 = parts.next()?.parse().ok()?;
let patch_part = parts.next()?;
let patch_str: String = patch_part
.chars()
.take_while(|c| c.is_ascii_digit())
.collect();
if patch_str.is_empty() {
return None;
}
let patch: u32 = patch_str.parse().ok()?;
Some((major, minor, patch))
}