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