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()
}
pub(super) const UPDATE_PROBE_PAGE_SIZE: usize = 30;
pub(super) const GITHUB_RELEASES_URL: &str =
"https://api.github.com/repos/oakoss/linesmith/releases?per_page=30";
const UPDATE_PROBE_TIMEOUT_SECS: u64 = 2;
pub(super) const UPDATE_PROBE_MAX_BYTES: u64 = 4 * 1024 * 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_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 Some(local) = parse_three_part_version(current_version) else {
return DoctorUpdateProbe::ParseError {
message: format!("local version {current_version} unparseable as MAJOR.MINOR.PATCH"),
};
};
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(entries) = value.as_array() else {
return DoctorUpdateProbe::ParseError {
message: format!(
"response was not a JSON array (got {})",
json_shape_name(&value)
),
};
};
let scanned = entries.len();
let chosen = entries.iter().enumerate().find_map(|(idx, entry)| {
match entry.get("draft") {
None | Some(serde_json::Value::Null) | Some(serde_json::Value::Bool(false)) => {}
Some(serde_json::Value::Bool(true)) => {
linesmith_core::lsm_debug!(
"doctor.probe: entry {idx} skipped — draft release"
);
return None;
}
Some(other) => {
linesmith_core::lsm_debug!(
"doctor.probe: entry {idx} skipped — `draft` field is {}, not bool (conservative skip)",
json_shape_name(other)
);
return None;
}
}
match entry.get("prerelease") {
None | Some(serde_json::Value::Null) | Some(serde_json::Value::Bool(false)) => {}
Some(serde_json::Value::Bool(true)) => {
linesmith_core::lsm_debug!(
"doctor.probe: entry {idx} skipped — prerelease"
);
return None;
}
Some(other) => {
linesmith_core::lsm_debug!(
"doctor.probe: entry {idx} skipped — `prerelease` field is {}, not bool (conservative skip)",
json_shape_name(other)
);
return None;
}
}
let Some(tag_value) = entry.get("tag_name") else {
linesmith_core::lsm_debug!(
"doctor.probe: entry {idx} skipped — no `tag_name` field"
);
return None;
};
let Some(tag) = tag_value.as_str() else {
linesmith_core::lsm_debug!(
"doctor.probe: entry {idx} skipped — `tag_name` is {}, not a string",
json_shape_name(tag_value)
);
return None;
};
let Some(parsed) = parse_binary_release_tag(tag) else {
linesmith_core::lsm_debug!(
"doctor.probe: entry {idx} skipped — {tag:?} is not a linesmith binary release tag"
);
return None;
};
Some((tag, parsed))
});
let Some((tag_name, remote)) = chosen else {
return DoctorUpdateProbe::NoBinaryRelease { scanned };
};
linesmith_core::lsm_debug!(
"doctor.probe: matched upstream tag {tag_name:?} ({}.{}.{}) — comparing against local {}.{}.{}",
remote.0,
remote.1,
remote.2,
local.0,
local.1,
local.2,
);
let safe_tag = sanitize_tag(tag_name);
if remote > local {
DoctorUpdateProbe::Newer { latest: safe_tag }
} else {
DoctorUpdateProbe::Latest
}
}
fn json_shape_name(v: &serde_json::Value) -> &'static str {
match v {
serde_json::Value::Null => "null",
serde_json::Value::Bool(_) => "boolean",
serde_json::Value::Number(_) => "number",
serde_json::Value::String(_) => "string",
serde_json::Value::Array(_) => "array",
serde_json::Value::Object(_) => "object",
}
}
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))
}
fn parse_binary_release_tag(s: &str) -> Option<(u32, u32, u32)> {
parse_three_part_version(strip_binary_package_prefix(s)?)
}
fn strip_binary_package_prefix(s: &str) -> Option<&str> {
if let Some(rest) = s.strip_prefix("linesmith/") {
if rest.starts_with('v') && rest[1..].starts_with(|c: char| c.is_ascii_digit()) {
return Some(rest);
}
return None;
}
if let Some(rest) = s.strip_prefix("linesmith-") {
if rest.starts_with('v') && rest[1..].starts_with(|c: char| c.is_ascii_digit()) {
return Some(rest);
}
return None;
}
if s.starts_with('v') && s[1..].starts_with(|c: char| c.is_ascii_digit()) {
return Some(s);
}
None
}