use camino::Utf8PathBuf;
use serde::{Deserialize, Serialize};
pub const MANIFEST_NAME: &str = "doiget.install.json";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct InstallManifest {
pub installer: String,
pub version: String,
#[serde(default)]
pub asset: Option<String>,
#[serde(default)]
pub sha256: Option<String>,
#[serde(default)]
pub installed_at: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct InstallInfo {
pub version: &'static str,
pub channel: &'static str,
pub binary: Option<Utf8PathBuf>,
pub method: &'static str,
pub manifest: Option<InstallManifest>,
pub manifest_matches: Option<bool>,
pub update: &'static str,
pub check: &'static str,
}
#[must_use]
pub fn install_info() -> InstallInfo {
let binary = std::env::current_exe()
.ok()
.and_then(|p| std::fs::canonicalize(&p).ok().or(Some(p)))
.and_then(|p| Utf8PathBuf::from_path_buf(p).ok());
let manifest = binary
.as_ref()
.and_then(|b| b.parent().map(|d| d.join(MANIFEST_NAME)))
.and_then(|p| std::fs::read_to_string(p).ok())
.and_then(|s| parse_manifest(&s));
describe(crate::VERSION, binary, manifest)
}
#[must_use]
pub fn parse_manifest(text: &str) -> Option<InstallManifest> {
serde_json::from_str(text.trim_start_matches('\u{feff}')).ok()
}
pub const UNKNOWN_VERSION: &str = "unknown";
#[must_use]
pub fn describe(
version: &'static str,
binary: Option<Utf8PathBuf>,
manifest: Option<InstallManifest>,
) -> InstallInfo {
let method = match &manifest {
Some(m) if m.installer == "install.ps1" => "install.ps1",
Some(_) => "install.sh",
None => binary
.as_ref()
.map_or("unknown", |b| method_from_path(b.as_str())),
};
let manifest_matches = manifest
.as_ref()
.filter(|m| m.version != UNKNOWN_VERSION)
.map(|m| m.version == version);
InstallInfo {
version,
channel: if version.contains('-') {
"beta"
} else {
"stable"
},
binary,
method,
manifest,
manifest_matches,
update: update_command(method),
check: "doiget version --check (asks GitHub Releases; doiget never checks on its own)",
}
}
fn method_from_path(path: &str) -> &'static str {
let p = path.replace('\\', "/").to_lowercase();
if p.contains("/node_modules/") {
"npm"
} else if p.contains("/.cargo/bin/") {
"cargo"
} else if p.contains("/cellar/") || p.contains("/homebrew/") || p.contains("/linuxbrew/") {
"homebrew"
} else if p.starts_with("/nix/store/") {
"nix"
} else if p.contains("claude extensions") || p.contains("/claude/extensions/") {
"mcpb"
} else {
"unknown"
}
}
fn update_command(method: &str) -> &'static str {
match method {
"install.sh" => {
"re-run: curl -fsSL https://raw.githubusercontent.com/QAtlasHub/doiget/main/scripts/install.sh | sh"
}
"install.ps1" => {
"re-run: irm https://raw.githubusercontent.com/QAtlasHub/doiget/main/scripts/install.ps1 | iex"
}
"npm" => {
"npm install -g doiget-cli@latest (npx -y doiget-cli runs the latest unless a version is pinned)"
}
"cargo" => "cargo install doiget-cli --locked",
"homebrew" => "brew upgrade doiget",
"nix" => "nix profile upgrade doiget (or update the flake input)",
"mcpb" => {
"install the .mcpb from the latest GitHub release: a Desktop Extension is not updated automatically"
}
_ => "reinstall from https://github.com/QAtlasHub/doiget/releases/latest",
}
}
#[cfg(test)]
#[allow(clippy::expect_used, clippy::unwrap_used)]
mod tests {
use super::*;
fn m(installer: &str, version: &str) -> InstallManifest {
InstallManifest {
installer: installer.into(),
version: version.into(),
asset: None,
sha256: None,
installed_at: None,
}
}
#[test]
fn the_channel_follows_the_version() {
assert_eq!(describe("0.9.0", None, None).channel, "stable");
assert_eq!(describe("0.9.0-beta.3", None, None).channel, "beta");
assert_eq!(describe("0.9.0-rc.1", None, None).channel, "beta");
}
#[test]
fn a_manifest_with_a_bom_parses_and_a_malformed_one_is_no_manifest() {
let json = r#"{"installer":"install.ps1","version":"0.9.0"}"#;
let with_bom = format!("\u{feff}{json}");
assert_eq!(
parse_manifest(&with_bom).map(|m| m.installer),
Some("install.ps1".to_string())
);
assert_eq!(parse_manifest("{not json"), None);
assert_eq!(
parse_manifest(r#"{"version":"0.9.0"}"#),
None,
"no installer"
);
}
#[test]
fn an_unknown_manifest_version_is_not_called_a_replacement() {
let info = describe("0.9.0", None, Some(m("install.sh", UNKNOWN_VERSION)));
assert_eq!(info.method, "install.sh");
assert_eq!(info.manifest_matches, None);
}
#[test]
fn a_manifest_names_the_installer_and_a_replaced_binary_is_flagged() {
let bin = Some(Utf8PathBuf::from(
"C:/Users/u/AppData/Local/Programs/doiget/doiget.exe",
));
let info = describe("0.8.12", bin.clone(), Some(m("install.ps1", "0.8.9")));
assert_eq!(info.method, "install.ps1");
assert_eq!(info.manifest_matches, Some(false));
assert!(info.update.contains("install.ps1"));
let info = describe("0.8.12", bin, Some(m("install.ps1", "0.8.12")));
assert_eq!(info.manifest_matches, Some(true));
}
#[test]
fn without_a_manifest_the_method_is_read_from_the_path() {
for (path, method) in [
(
"/home/u/.npm/_npx/abc/node_modules/doiget-cli-linux-x64/bin/doiget",
"npm",
),
("/home/u/.cargo/bin/doiget", "cargo"),
("/opt/homebrew/Cellar/doiget/0.8.13/bin/doiget", "homebrew"),
("/nix/store/abc-doiget-0.8.13/bin/doiget", "nix"),
(
"/Users/u/Library/Application Support/Claude/Claude Extensions/doiget/server/doiget",
"mcpb",
),
("/usr/local/bin/doiget", "unknown"),
] {
let info = describe("0.9.0", Some(Utf8PathBuf::from(path)), None);
assert_eq!(info.method, method, "{path}");
assert_eq!(info.manifest_matches, None);
}
assert!(describe("0.9.0", None, None)
.check
.contains("doiget version --check"));
}
}