use std::ffi::OsString;
use std::path::{Path, PathBuf};
use std::process::Command;
use serde::{Deserialize, Serialize};
pub(crate) const VERIFIED_DSH_VERSION: &str = "0.1.0-rc.6";
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub(crate) struct DshVersion {
pub(crate) major: u64,
pub(crate) minor: u64,
pub(crate) patch: u64,
pub(crate) rc: Option<u64>,
}
impl DshVersion {
pub(crate) fn parse(raw: &str) -> Option<Self> {
let raw = raw.trim().trim_start_matches('v');
let (base, pre) = match raw.split_once('-') {
Some((base, pre)) => (base, Some(pre)),
None => (raw, None),
};
let mut parts = base.split('.');
let major = parts.next()?.parse().ok()?;
let minor = parts.next()?.parse().ok()?;
let patch = parts.next()?.parse().ok()?;
if parts.next().is_some() {
return None;
}
let rc = match pre {
None => None,
Some(pre) => {
let n = pre.strip_prefix("rc.")?.parse().ok()?;
Some(n)
}
};
Some(Self {
major,
minor,
patch,
rc,
})
}
fn base(self) -> (u64, u64, u64) {
(self.major, self.minor, self.patch)
}
fn cmp_semver(self, other: Self) -> std::cmp::Ordering {
use std::cmp::Ordering;
match self.base().cmp(&other.base()) {
Ordering::Equal => match (self.rc, other.rc) {
(None, None) => Ordering::Equal,
(None, Some(_)) => Ordering::Greater,
(Some(_), None) => Ordering::Less,
(Some(a), Some(b)) => a.cmp(&b),
},
ordering => ordering,
}
}
}
impl std::fmt::Display for DshVersion {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}.{}.{}", self.major, self.minor, self.patch)?;
if let Some(rc) = self.rc {
write!(f, "-rc.{rc}")?;
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub(crate) enum DshCompatibility {
Verified,
NewerUnverified { verified: String },
Incompatible { reason: String },
Offline { reason: String },
Unparsed { raw: String },
}
impl DshCompatibility {
pub(crate) fn label(&self) -> &'static str {
match self {
Self::Verified => "verified",
Self::NewerUnverified { .. } => "newer-unverified",
Self::Incompatible { .. } => "incompatible",
Self::Offline { .. } => "offline",
Self::Unparsed { .. } => "unparsed",
}
}
}
pub(crate) fn classify_version(raw: &str, supports_patch: bool) -> DshCompatibility {
let Some(version) = DshVersion::parse(raw) else {
return DshCompatibility::Unparsed {
raw: raw.trim().to_string(),
};
};
let verified = DshVersion::parse(VERIFIED_DSH_VERSION).expect("verified version parses");
match version.cmp_semver(verified) {
std::cmp::Ordering::Less => DshCompatibility::Incompatible {
reason: format!("{version} is older than the verified {verified}"),
},
_ if !supports_patch => DshCompatibility::Incompatible {
reason: "launcher does not advertise --patch overlays".to_string(),
},
std::cmp::Ordering::Equal => DshCompatibility::Verified,
std::cmp::Ordering::Greater => DshCompatibility::NewerUnverified {
verified: verified.to_string(),
},
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub(crate) struct DshDetection {
pub(crate) binary: Option<PathBuf>,
pub(crate) version: Option<String>,
pub(crate) compatibility: DshCompatibility,
pub(crate) supports_patch: bool,
pub(crate) dsh_home: PathBuf,
pub(crate) dsh_home_exists: bool,
pub(crate) dsh_home_from_env: bool,
pub(crate) profiles: Vec<String>,
pub(crate) settings_namespaces: Vec<String>,
pub(crate) credentials_present: bool,
pub(crate) credentials_mode_ok: Option<bool>,
}
impl DshDetection {
pub(crate) fn installed(&self) -> bool {
self.binary.is_some()
}
}
#[derive(Debug, Clone)]
pub(crate) struct DetectEnv {
pub(crate) path: Option<OsString>,
pub(crate) home: Option<PathBuf>,
pub(crate) dsh_home: Option<OsString>,
}
impl DetectEnv {
pub(crate) fn from_process() -> Self {
Self {
path: std::env::var_os("PATH"),
home: dirs::home_dir(),
dsh_home: std::env::var_os("DSH_HOME"),
}
}
}
pub(crate) trait DshRunner {
fn run(&self, binary: &Path, args: &[&str]) -> std::io::Result<(bool, String)>;
}
pub(crate) struct ProcessRunner;
impl DshRunner for ProcessRunner {
fn run(&self, binary: &Path, args: &[&str]) -> std::io::Result<(bool, String)> {
let output = Command::new(binary)
.args(args)
.env("DSH_TELEMETRY_DISABLED", "1")
.stdin(std::process::Stdio::null())
.output()?;
let mut text = String::from_utf8_lossy(&output.stdout).into_owned();
text.push_str(&String::from_utf8_lossy(&output.stderr));
Ok((output.status.success(), text))
}
}
pub(crate) fn resolve_dsh_home(env: &DetectEnv) -> (PathBuf, bool) {
if let Some(raw) = env.dsh_home.as_ref() {
let text = raw.to_string_lossy();
let trimmed = text.trim();
if !trimmed.is_empty() {
if let Some(rest) = trimmed.strip_prefix("~/") {
if let Some(home) = env.home.as_ref() {
return (home.join(rest), true);
}
} else if trimmed != "~" {
return (PathBuf::from(trimmed), true);
}
}
}
let home = env.home.clone().unwrap_or_else(|| PathBuf::from("."));
(home.join(".dsh"), false)
}
fn find_on_path(path: Option<&OsString>) -> Option<PathBuf> {
let path = path?;
for dir in std::env::split_paths(path) {
if dir.as_os_str().is_empty() {
continue;
}
for name in ["dsh", "dsh.cmd", "dsh.exe"] {
let candidate = dir.join(name);
if candidate.is_file() {
return Some(candidate);
}
}
}
None
}
pub(crate) fn settings_namespaces(text: &str) -> Vec<String> {
let mut out = Vec::new();
for raw in text.lines().take(4096) {
if raw.starts_with([' ', '\t', '#', '-']) || raw.trim().is_empty() {
continue;
}
let Some((key, _)) = raw.split_once(':') else {
continue;
};
let key = key.trim();
if key.is_empty() || key.starts_with('"') || key.starts_with('\'') {
continue;
}
if !out.iter().any(|k| k == key) {
out.push(key.to_string());
}
}
out
}
pub(crate) fn detect(env: &DetectEnv, runner: &dyn DshRunner) -> DshDetection {
let (dsh_home, dsh_home_from_env) = resolve_dsh_home(env);
let dsh_home_exists = dsh_home.is_dir();
let mut profiles = Vec::new();
if let Ok(entries) = std::fs::read_dir(dsh_home.join("profiles")) {
for entry in entries.flatten() {
let name = entry.file_name().to_string_lossy().into_owned();
if name == "node_modules" || name.starts_with('.') {
continue;
}
if entry.path().is_dir() {
profiles.push(name);
}
}
}
profiles.sort();
let settings_namespaces = std::fs::read(dsh_home.join("settings.yaml"))
.ok()
.map(|bytes| {
let bytes = &bytes[..bytes.len().min(64 * 1024)];
settings_namespaces(&String::from_utf8_lossy(bytes))
})
.unwrap_or_default();
let credentials_path = dsh_home.join(".credentials.yaml");
let credentials_present = credentials_path.is_file();
let credentials_mode_ok = credentials_mode_ok(&credentials_path);
let binary = find_on_path(env.path.as_ref());
let Some(binary) = binary else {
return DshDetection {
binary: None,
version: None,
compatibility: DshCompatibility::Offline {
reason: "dsh is not on PATH".to_string(),
},
supports_patch: false,
dsh_home,
dsh_home_exists,
dsh_home_from_env,
profiles,
settings_namespaces,
credentials_present,
credentials_mode_ok,
};
};
let (version, supports_patch, compatibility) = match runner.run(&binary, &["--version"]) {
Ok((true, text)) => {
let version = text.trim().lines().last().unwrap_or("").trim().to_string();
let supports_patch = match runner.run(&binary, &["--help"]) {
Ok((_, help)) => help.contains("--patch"),
Err(_) => false,
};
let compatibility = classify_version(&version, supports_patch);
(Some(version), supports_patch, compatibility)
}
Ok((false, text)) => (
None,
false,
DshCompatibility::Offline {
reason: format!(
"dsh --version exited non-zero: {}",
text.trim()
.lines()
.next()
.unwrap_or("")
.chars()
.take(120)
.collect::<String>()
),
},
),
Err(error) => (
None,
false,
DshCompatibility::Offline {
reason: format!("dsh could not be run: {error}"),
},
),
};
DshDetection {
binary: Some(binary),
version,
compatibility,
supports_patch,
dsh_home,
dsh_home_exists,
dsh_home_from_env,
profiles,
settings_namespaces,
credentials_present,
credentials_mode_ok,
}
}
#[cfg(unix)]
fn credentials_mode_ok(path: &Path) -> Option<bool> {
use std::os::unix::fs::PermissionsExt;
let meta = std::fs::metadata(path).ok()?;
Some(meta.permissions().mode() & 0o077 == 0)
}
#[cfg(not(unix))]
fn credentials_mode_ok(_path: &Path) -> Option<bool> {
None
}