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