use crate::fleet_doctor_schema::HostOs;
use serde::{Deserialize, Serialize};
pub fn detect_host_os(uname_s: &str) -> HostOs {
let s = uname_s.trim().to_ascii_lowercase();
if s == "darwin" {
HostOs::MacOs
} else if s == "linux" {
HostOs::Linux
} else if s.starts_with("mingw")
|| s.starts_with("msys")
|| s.starts_with("cygwin")
|| s.contains("windows")
{
HostOs::Windows
} else {
HostOs::Other
}
}
pub fn default_data_dir(os: HostOs, home: &str, xdg_data_home: Option<&str>) -> String {
let home = home.trim_end_matches('/');
match os {
HostOs::MacOs => format!("{home}/Library/Application Support/cass"),
HostOs::Linux => match xdg_data_home.map(str::trim).filter(|s| !s.is_empty()) {
Some(xdg) => format!("{}/cass", xdg.trim_end_matches('/')),
None => format!("{home}/.local/share/cass"),
},
HostOs::Windows => format!("{}\\AppData\\Roaming\\cass", home.trim_end_matches('\\')),
HostOs::Other => format!("{home}/.cass"),
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum PathOrigin {
MacUsersHome,
LinuxHome,
DpCheckout,
Other,
}
pub fn classify_path_origin(path: &str) -> PathOrigin {
let p = path.trim();
if p.starts_with("/Users/") {
PathOrigin::MacUsersHome
} else if p.starts_with("/home/") {
PathOrigin::LinuxHome
} else if p.starts_with("/dp/") || p.starts_with("/data/projects/") {
PathOrigin::DpCheckout
} else {
PathOrigin::Other
}
}
pub fn redact_user_path(path: &str) -> String {
for prefix in ["/Users/", "/home/"] {
if let Some(rest) = path.strip_prefix(prefix) {
let tail = rest.split_once('/').map(|(_, t)| t);
return match tail {
Some(t) => format!("{prefix}<user>/{t}"),
None => format!("{prefix}<user>"),
};
}
}
path.to_string()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct PortableCommand {
pub command: &'static str,
pub avoids: &'static str,
}
pub const PORTABLE_ISO_TIMESTAMP: PortableCommand = PortableCommand {
command: "date -u +%Y-%m-%dT%H:%M:%SZ",
avoids: "date -Is / date --iso-8601=seconds (GNU-only)",
};
pub const PORTABLE_EPOCH_SECONDS: PortableCommand = PortableCommand {
command: "date -u +%s",
avoids: "date --utc=@... arithmetic (GNU-only)",
};
pub fn tool_notes_for(os: HostOs) -> Vec<String> {
match os {
HostOs::MacOs => vec![
"date=bsd".to_string(),
"coreutils=bsd".to_string(),
"data_dir=library-application-support".to_string(),
"home=/Users".to_string(),
],
HostOs::Windows => vec![
"shell=non-posix".to_string(),
"data_dir=appdata".to_string(),
],
HostOs::Linux | HostOs::Other => Vec::new(),
}
}
pub fn has_unguarded_gnuism(snippet: &str) -> bool {
const GNUISMS: &[&str] = &[
"date -Is",
"date --iso-8601",
"readlink -f", "sed -r", "grep -P", "stat -c", ];
GNUISMS.iter().any(|g| snippet.contains(g))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn detects_darwin_and_linux_uname() {
assert_eq!(detect_host_os("Darwin"), HostOs::MacOs);
assert_eq!(detect_host_os("darwin\n"), HostOs::MacOs);
assert_eq!(detect_host_os(" Linux "), HostOs::Linux);
assert_eq!(detect_host_os("MINGW64_NT-10.0"), HostOs::Windows);
assert_eq!(detect_host_os("FreeBSD"), HostOs::Other);
}
#[test]
fn macos_data_dir_uses_application_support() {
let dir = default_data_dir(HostOs::MacOs, "/Users/alice", None);
assert_eq!(dir, "/Users/alice/Library/Application Support/cass");
}
#[test]
fn linux_data_dir_uses_xdg_then_local_share() {
assert_eq!(
default_data_dir(HostOs::Linux, "/home/bob", Some("/home/bob/.xdg")),
"/home/bob/.xdg/cass"
);
assert_eq!(
default_data_dir(HostOs::Linux, "/home/bob", None),
"/home/bob/.local/share/cass"
);
assert_eq!(
default_data_dir(HostOs::Linux, "/home/bob", Some(" ")),
"/home/bob/.local/share/cass"
);
}
#[test]
fn data_dir_trims_trailing_home_slash() {
assert_eq!(
default_data_dir(HostOs::MacOs, "/Users/alice/", None),
"/Users/alice/Library/Application Support/cass"
);
}
#[test]
fn classifies_users_home_dp_and_linux_paths() {
assert_eq!(
classify_path_origin("/Users/alice/.claude"),
PathOrigin::MacUsersHome
);
assert_eq!(
classify_path_origin("/home/bob/.codex"),
PathOrigin::LinuxHome
);
assert_eq!(
classify_path_origin("/dp/frankensqlite"),
PathOrigin::DpCheckout
);
assert_eq!(
classify_path_origin("/data/projects/coding_agent_session_search"),
PathOrigin::DpCheckout
);
assert_eq!(classify_path_origin("/opt/whatever"), PathOrigin::Other);
}
#[test]
fn redacts_username_from_home_paths() {
assert_eq!(
redact_user_path("/Users/alice/.claude/x"),
"/Users/<user>/.claude/x"
);
assert_eq!(redact_user_path("/home/bob/.codex"), "/home/<user>/.codex");
assert_eq!(redact_user_path("/Users/alice"), "/Users/<user>");
assert_eq!(redact_user_path("/data/projects/x"), "/data/projects/x");
assert_eq!(redact_user_path("/opt/cass"), "/opt/cass");
}
#[test]
fn portable_timestamp_avoids_gnu_only_flags() {
assert_eq!(
PORTABLE_ISO_TIMESTAMP.command,
"date -u +%Y-%m-%dT%H:%M:%SZ"
);
assert!(!PORTABLE_ISO_TIMESTAMP.command.contains("-Is"));
assert!(!PORTABLE_ISO_TIMESTAMP.command.contains("--iso-8601"));
assert!(PORTABLE_ISO_TIMESTAMP.avoids.contains("GNU-only"));
assert!(PORTABLE_EPOCH_SECONDS.command.contains("+%s"));
}
#[test]
fn tool_notes_flag_macos_bsd_divergences_and_are_empty_on_linux() {
let mac = tool_notes_for(HostOs::MacOs);
assert!(mac.iter().any(|n| n == "date=bsd"));
assert!(mac.iter().any(|n| n.starts_with("data_dir=")));
assert!(tool_notes_for(HostOs::Linux).is_empty());
}
#[test]
fn detects_unguarded_gnuisms() {
assert!(has_unguarded_gnuism("ts=$(date -Is)"));
assert!(has_unguarded_gnuism("p=$(readlink -f \"$x\")"));
assert!(has_unguarded_gnuism("sed -r 's/a/b/'"));
assert!(has_unguarded_gnuism("grep -P '\\d+'"));
assert!(has_unguarded_gnuism("stat -c %s file"));
assert!(!has_unguarded_gnuism(PORTABLE_ISO_TIMESTAMP.command));
assert!(!has_unguarded_gnuism("realpath \"$x\""));
}
#[test]
fn path_origin_wire_values_are_kebab_case() {
for (origin, wire) in [
(PathOrigin::MacUsersHome, "mac-users-home"),
(PathOrigin::LinuxHome, "linux-home"),
(PathOrigin::DpCheckout, "dp-checkout"),
(PathOrigin::Other, "other"),
] {
assert_eq!(
serde_json::to_string(&origin).unwrap(),
format!("\"{wire}\"")
);
}
}
}