1use anyhow::{Context, Result};
12use semver::Version;
13use std::process::Command;
14
15pub const PINNED_LORE_VERSION: &str = "0.8.4-portals.9";
20
21pub const PINNED_LORE_REPOSITORY: &str = "portalshq/lore";
24
25pub const PINNED_LORE_INSTALLER_SHA256: &str =
29 "8e7cc96d1b9100610af6c1bd15ec2febbcb48d26cc7f19de3862496897810b74";
30
31pub const PINNED_LORE_ARTIFACT_MANIFEST_SHA256: &str =
35 "sha256:dc853999309ec32e2c91acb94f7ef2aad4f080ac4a0f7591d9371d5cd57eb089";
36pub const PINNED_LORE_ARTIFACT_MANIFEST_URL: &str =
37 "https://github.com/portalshq/lore/releases/download/v0.8.4-portals.9/SHA256SUMS";
38pub const PINNED_LORE_SIGNATURE_BUNDLE_URL: &str =
39 "https://github.com/portalshq/lore/releases/download/v0.8.4-portals.9/SHA256SUMS.sigstore.json";
40
41#[derive(Debug, Clone, PartialEq, Eq)]
49pub struct LoreVersionInfo {
50 pub parsed: Version,
53 pub raw: String,
56}
57
58impl std::fmt::Display for LoreVersionInfo {
59 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
60 write!(f, "{}", self.raw)
61 }
62}
63
64fn extract_version_string(version_str: &str) -> Result<String> {
70 let version_part = version_str.split_whitespace().nth(1).context(format!(
71 "Failed to parse Lore version string '{}'. \
72 Expected format: 'lore <version>' (e.g., 'lore 0.8.4')",
73 version_str.trim()
74 ))?;
75 Ok(version_part.trim().to_string())
76}
77
78pub fn detect_lore_version() -> Result<LoreVersionInfo> {
80 let output = Command::new("lore").arg("--version").output().context(
81 "Failed to execute 'lore --version'. \
82 Lore CLI is not installed or not on PATH. \
83 Install it with: nap install lore",
84 )?;
85
86 if !output.status.success() {
87 let stderr = String::from_utf8_lossy(&output.stderr);
88 anyhow::bail!(
89 "lore --version exited with status: {}. stderr: {}",
90 output.status,
91 stderr.trim()
92 );
93 }
94
95 let version_str = String::from_utf8_lossy(&output.stdout);
96 let raw = extract_version_string(&version_str)?;
97 let parsed = parse_lore_version(&version_str)?;
98 Ok(LoreVersionInfo { parsed, raw })
99}
100
101pub fn detect_loreserver_version() -> Result<LoreVersionInfo> {
103 let output = Command::new("loreserver")
104 .arg("--version")
105 .output()
106 .context(
107 "Failed to execute 'loreserver --version'. \
108 Lore server is not installed or not on PATH. \
109 Install it with: nap install lore",
110 )?;
111
112 if !output.status.success() {
113 let stderr = String::from_utf8_lossy(&output.stderr);
114 anyhow::bail!(
115 "loreserver --version exited with status: {}. stderr: {}",
116 output.status,
117 stderr.trim()
118 );
119 }
120
121 let version_str = String::from_utf8_lossy(&output.stdout);
122 let raw = extract_version_string(&version_str)?;
123 let parsed = parse_lore_version(&version_str)?;
124 Ok(LoreVersionInfo { parsed, raw })
125}
126
127fn parse_lore_version(version_str: &str) -> Result<Version> {
135 let version_part = version_str.split_whitespace().nth(1).context(format!(
137 "Failed to parse Lore version string '{}'. \
138 Expected format: 'lore <version>' (e.g., 'lore 0.8.4')",
139 version_str.trim()
140 ))?;
141
142 let version_for_semver = version_part.trim_end_matches("-nightly");
144
145 Version::parse(version_for_semver).context(format!(
146 "Failed to parse '{}' as semver version. \
147 Lore version string may be in an unexpected format.",
148 version_for_semver
149 ))
150}
151
152pub fn check_lore_compatibility(installed: &LoreVersionInfo) -> Result<bool> {
160 let installed_version = installed.raw.split('+').next().unwrap_or(&installed.raw);
161 Ok(installed_version == PINNED_LORE_VERSION)
162}
163
164pub fn verify_lore_installation() -> Result<LoreInstallationStatus> {
168 let cli_version = match detect_lore_version() {
169 Ok(v) => Some(v),
170 Err(e) => {
171 tracing::debug!("Lore CLI not detected: {}", e);
172 None
173 }
174 };
175
176 let server_version = match detect_loreserver_version() {
177 Ok(v) => Some(v),
178 Err(e) => {
179 tracing::debug!("Lore server not detected: {}", e);
180 None
181 }
182 };
183
184 let cli_compatible = cli_version
185 .as_ref()
186 .map(|v| check_lore_compatibility(v).unwrap_or(false))
187 .unwrap_or(false);
188
189 let server_compatible = server_version
190 .as_ref()
191 .map(|v| check_lore_compatibility(v).unwrap_or(false))
192 .unwrap_or(false);
193
194 Ok(LoreInstallationStatus {
195 cli_installed: cli_version.is_some(),
196 cli_version,
197 cli_compatible,
198 server_installed: server_version.is_some(),
199 server_version,
200 server_compatible,
201 pinned_version: PINNED_LORE_VERSION.to_string(),
202 })
203}
204
205#[derive(Debug, Clone)]
209pub struct LoreInstallationStatus {
210 pub cli_installed: bool,
211 pub cli_version: Option<LoreVersionInfo>,
212 pub cli_compatible: bool,
213 pub server_installed: bool,
214 pub server_version: Option<LoreVersionInfo>,
215 pub server_compatible: bool,
216 pub pinned_version: String,
217}
218
219impl LoreInstallationStatus {
220 pub fn is_fully_compatible(&self) -> bool {
222 self.cli_installed && self.cli_compatible && self.server_installed && self.server_compatible
223 }
224
225 pub fn status_message(&self) -> String {
227 let mut messages = vec![];
228
229 if !self.cli_installed {
230 messages.push("Lore CLI is not installed".to_string());
231 } else if !self.cli_compatible {
232 messages.push(format!(
233 "Lore CLI version '{}' is incompatible with required version '{}'",
234 self.cli_version
235 .as_ref()
236 .map(|v| v.raw.as_str())
237 .unwrap_or("unknown"),
238 self.pinned_version
239 ));
240 }
241
242 if !self.server_installed {
243 messages.push("Lore server is not installed".to_string());
244 } else if !self.server_compatible {
245 messages.push(format!(
246 "Lore server version '{}' is incompatible with required version '{}'",
247 self.server_version
248 .as_ref()
249 .map(|v| v.raw.as_str())
250 .unwrap_or("unknown"),
251 self.pinned_version
252 ));
253 }
254
255 if messages.is_empty() {
256 "Lore installation is compatible".to_string()
257 } else {
258 messages.join("; ")
259 }
260 }
261}
262
263#[cfg(test)]
266mod tests {
267 use super::*;
268
269 #[test]
270 fn test_extract_version_string() {
271 assert_eq!(extract_version_string("lore 0.8.4").unwrap(), "0.8.4");
272 assert_eq!(extract_version_string("loreserver 0.8.4").unwrap(), "0.8.4");
273 assert_eq!(extract_version_string("lore 0.8.4\n").unwrap(), "0.8.4");
274 }
275
276 #[test]
277 fn test_extract_version_string_failure() {
278 assert!(extract_version_string("lore").is_err());
280 assert!(extract_version_string("").is_err());
282 }
283
284 #[test]
285 fn test_parse_lore_version() {
286 let version_str = "lore 0.8.4";
287 let version = parse_lore_version(version_str).unwrap();
288 assert_eq!(version.major, 0);
289 assert_eq!(version.minor, 8);
290 assert_eq!(version.patch, 4);
291 }
292
293 #[test]
294 fn test_parse_lore_version_with_nightly_suffix() {
295 let version_str = "lore 0.8.4-nightly";
297 let version = parse_lore_version(version_str).unwrap();
298 assert_eq!(version.major, 0);
299 assert_eq!(version.minor, 8);
300 assert_eq!(version.patch, 4);
301 }
302
303 #[test]
304 fn test_parse_loreserver_version() {
305 let version_str = "loreserver 0.8.4";
306 let version = parse_lore_version(version_str).unwrap();
307 assert_eq!(version.major, 0);
308 assert_eq!(version.minor, 8);
309 assert_eq!(version.patch, 4);
310 }
311
312 #[test]
313 fn test_compatibility_exact_match() {
314 let installed = LoreVersionInfo {
315 parsed: Version::new(0, 8, 4),
316 raw: PINNED_LORE_VERSION.to_string(),
317 };
318 assert!(check_lore_compatibility(&installed).unwrap());
319 }
320
321 #[test]
322 fn test_compatibility_ignores_build_metadata() {
323 let installed = LoreVersionInfo {
326 parsed: Version::new(0, 8, 4),
327 raw: format!("{}+283", PINNED_LORE_VERSION),
328 };
329 assert!(check_lore_compatibility(&installed).unwrap());
330 }
331
332 #[test]
333 fn test_compatibility_rejects_nightly_suffix() {
334 let installed = LoreVersionInfo {
336 parsed: Version::new(0, 8, 4),
337 raw: "0.8.4-nightly".to_string(),
338 };
339 assert!(!check_lore_compatibility(&installed).unwrap());
340 }
341
342 #[test]
343 fn test_compatibility_rejects_wrong_channel() {
344 let installed = LoreVersionInfo {
345 parsed: Version::new(0, 8, 4),
346 raw: "0.8.4-stable".to_string(),
347 };
348 assert!(!check_lore_compatibility(&installed).unwrap());
349 }
350
351 #[test]
352 fn test_compatibility_rejects_wrong_version() {
353 let installed = LoreVersionInfo {
354 parsed: Version::new(0, 7, 0),
355 raw: "0.7.0".to_string(),
356 };
357 assert!(!check_lore_compatibility(&installed).unwrap());
358 }
359
360 #[test]
361 fn test_installation_status_message() {
362 let status = LoreInstallationStatus {
363 cli_installed: false,
364 cli_version: None,
365 cli_compatible: false,
366 server_installed: false,
367 server_version: None,
368 server_compatible: false,
369 pinned_version: PINNED_LORE_VERSION.to_string(),
370 };
371
372 let message = status.status_message();
373 assert!(message.contains("Lore CLI is not installed"));
374 assert!(message.contains("Lore server is not installed"));
375 }
376
377 #[test]
378 fn test_installation_status_message_incompatible() {
379 let status = LoreInstallationStatus {
380 cli_installed: true,
381 cli_version: Some(LoreVersionInfo {
382 parsed: Version::new(0, 8, 4),
383 raw: "0.8.4-nightly".to_string(),
384 }),
385 cli_compatible: false,
386 server_installed: true,
387 server_version: Some(LoreVersionInfo {
388 parsed: Version::new(0, 8, 4),
389 raw: "0.8.4-nightly".to_string(),
390 }),
391 server_compatible: false,
392 pinned_version: PINNED_LORE_VERSION.to_string(),
393 };
394
395 let message = status.status_message();
396 assert!(message.contains("'0.8.4-nightly'"));
397 assert!(message.contains(&format!("'{}'", PINNED_LORE_VERSION)));
398 assert!(!status.is_fully_compatible());
399 }
400
401 #[test]
402 fn test_pinned_version_constant() {
403 assert_eq!(PINNED_LORE_VERSION, "0.8.4-portals.9");
407 }
408}