1use camino::Utf8PathBuf;
24use serde::{Deserialize, Serialize};
25
26pub const MANIFEST_NAME: &str = "doiget.install.json";
28
29#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
31pub struct InstallManifest {
32 pub installer: String,
34 pub version: String,
36 #[serde(default)]
38 pub asset: Option<String>,
39 #[serde(default)]
41 pub sha256: Option<String>,
42 #[serde(default)]
44 pub installed_at: Option<String>,
45}
46
47#[derive(Debug, Clone, Serialize)]
49pub struct InstallInfo {
50 pub version: &'static str,
52 pub channel: &'static str,
54 pub binary: Option<Utf8PathBuf>,
56 pub method: &'static str,
59 pub manifest: Option<InstallManifest>,
61 pub manifest_matches: Option<bool>,
64 pub update: &'static str,
66 pub check: &'static str,
69}
70
71#[must_use]
73pub fn install_info() -> InstallInfo {
74 let binary = std::env::current_exe()
75 .ok()
76 .and_then(|p| std::fs::canonicalize(&p).ok().or(Some(p)))
77 .and_then(|p| Utf8PathBuf::from_path_buf(p).ok());
78 let manifest = binary
79 .as_ref()
80 .and_then(|b| b.parent().map(|d| d.join(MANIFEST_NAME)))
81 .and_then(|p| std::fs::read_to_string(p).ok())
82 .and_then(|s| parse_manifest(&s));
83 describe(crate::VERSION, binary, manifest)
84}
85
86#[must_use]
92pub fn parse_manifest(text: &str) -> Option<InstallManifest> {
93 serde_json::from_str(text.trim_start_matches('\u{feff}')).ok()
94}
95
96pub const UNKNOWN_VERSION: &str = "unknown";
99
100#[must_use]
102pub fn describe(
103 version: &'static str,
104 binary: Option<Utf8PathBuf>,
105 manifest: Option<InstallManifest>,
106) -> InstallInfo {
107 let method = match &manifest {
108 Some(m) if m.installer == "install.ps1" => "install.ps1",
109 Some(_) => "install.sh",
110 None => binary
111 .as_ref()
112 .map_or("unknown", |b| method_from_path(b.as_str())),
113 };
114 let manifest_matches = manifest
115 .as_ref()
116 .filter(|m| m.version != UNKNOWN_VERSION)
117 .map(|m| m.version == version);
118 InstallInfo {
119 version,
120 channel: if version.contains('-') {
121 "beta"
122 } else {
123 "stable"
124 },
125 binary,
126 method,
127 manifest,
128 manifest_matches,
129 update: update_command(method),
130 check: "doiget version --check (asks GitHub Releases; doiget never checks on its own)",
131 }
132}
133
134fn method_from_path(path: &str) -> &'static str {
136 let p = path.replace('\\', "/").to_lowercase();
137 if p.contains("/node_modules/") {
138 "npm"
139 } else if p.contains("/.cargo/bin/") {
140 "cargo"
141 } else if p.contains("/cellar/") || p.contains("/homebrew/") || p.contains("/linuxbrew/") {
142 "homebrew"
143 } else if p.starts_with("/nix/store/") {
144 "nix"
145 } else if p.contains("claude extensions") || p.contains("/claude/extensions/") {
146 "mcpb"
147 } else {
148 "unknown"
149 }
150}
151
152fn update_command(method: &str) -> &'static str {
153 match method {
154 "install.sh" => {
155 "re-run: curl -fsSL https://raw.githubusercontent.com/QAtlasHub/doiget/main/scripts/install.sh | sh"
156 }
157 "install.ps1" => {
158 "re-run: irm https://raw.githubusercontent.com/QAtlasHub/doiget/main/scripts/install.ps1 | iex"
159 }
160 "npm" => {
161 "npm install -g doiget-cli@latest (npx -y doiget-cli runs the latest unless a version is pinned)"
162 }
163 "cargo" => "cargo install doiget-cli --locked",
164 "homebrew" => "brew upgrade doiget",
165 "nix" => "nix profile upgrade doiget (or update the flake input)",
166 "mcpb" => {
167 "install the .mcpb from the latest GitHub release: a Desktop Extension is not updated automatically"
168 }
169 _ => "reinstall from https://github.com/QAtlasHub/doiget/releases/latest",
170 }
171}
172
173#[cfg(test)]
174#[allow(clippy::expect_used, clippy::unwrap_used)]
175mod tests {
176 use super::*;
177
178 fn m(installer: &str, version: &str) -> InstallManifest {
179 InstallManifest {
180 installer: installer.into(),
181 version: version.into(),
182 asset: None,
183 sha256: None,
184 installed_at: None,
185 }
186 }
187
188 #[test]
189 fn the_channel_follows_the_version() {
190 assert_eq!(describe("0.9.0", None, None).channel, "stable");
191 assert_eq!(describe("0.9.0-beta.3", None, None).channel, "beta");
192 assert_eq!(describe("0.9.0-rc.1", None, None).channel, "beta");
194 }
195
196 #[test]
197 fn a_manifest_with_a_bom_parses_and_a_malformed_one_is_no_manifest() {
198 let json = r#"{"installer":"install.ps1","version":"0.9.0"}"#;
199 let with_bom = format!("\u{feff}{json}");
200 assert_eq!(
201 parse_manifest(&with_bom).map(|m| m.installer),
202 Some("install.ps1".to_string())
203 );
204 assert_eq!(parse_manifest("{not json"), None);
205 assert_eq!(
206 parse_manifest(r#"{"version":"0.9.0"}"#),
207 None,
208 "no installer"
209 );
210 }
211
212 #[test]
213 fn an_unknown_manifest_version_is_not_called_a_replacement() {
214 let info = describe("0.9.0", None, Some(m("install.sh", UNKNOWN_VERSION)));
215 assert_eq!(info.method, "install.sh");
216 assert_eq!(info.manifest_matches, None);
217 }
218
219 #[test]
222 fn a_manifest_names_the_installer_and_a_replaced_binary_is_flagged() {
223 let bin = Some(Utf8PathBuf::from(
224 "C:/Users/u/AppData/Local/Programs/doiget/doiget.exe",
225 ));
226 let info = describe("0.8.12", bin.clone(), Some(m("install.ps1", "0.8.9")));
227 assert_eq!(info.method, "install.ps1");
228 assert_eq!(info.manifest_matches, Some(false));
229 assert!(info.update.contains("install.ps1"));
230 let info = describe("0.8.12", bin, Some(m("install.ps1", "0.8.12")));
231 assert_eq!(info.manifest_matches, Some(true));
232 }
233
234 #[test]
235 fn without_a_manifest_the_method_is_read_from_the_path() {
236 for (path, method) in [
237 (
238 "/home/u/.npm/_npx/abc/node_modules/doiget-cli-linux-x64/bin/doiget",
239 "npm",
240 ),
241 ("/home/u/.cargo/bin/doiget", "cargo"),
242 ("/opt/homebrew/Cellar/doiget/0.8.13/bin/doiget", "homebrew"),
243 ("/nix/store/abc-doiget-0.8.13/bin/doiget", "nix"),
244 (
245 "/Users/u/Library/Application Support/Claude/Claude Extensions/doiget/server/doiget",
246 "mcpb",
247 ),
248 ("/usr/local/bin/doiget", "unknown"),
249 ] {
250 let info = describe("0.9.0", Some(Utf8PathBuf::from(path)), None);
251 assert_eq!(info.method, method, "{path}");
252 assert_eq!(info.manifest_matches, None);
253 }
254 assert!(describe("0.9.0", None, None)
255 .check
256 .contains("doiget version --check"));
257 }
258}