1use anyhow::{Context, Result};
14use semver::Version;
15use std::process::Command;
16
17pub const PINNED_LORE_VERSION: &str = "0.8.5-nightly";
22
23#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct LoreVersionInfo {
32 pub parsed: Version,
35 pub raw: String,
38}
39
40impl std::fmt::Display for LoreVersionInfo {
41 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
42 write!(f, "{}", self.raw)
43 }
44}
45
46fn extract_version_string(version_str: &str) -> Result<String> {
52 let version_part = version_str.split_whitespace().nth(1).context(format!(
53 "Failed to parse Lore version string '{}'. \
54 Expected format: 'lore <version>' (e.g., 'lore 0.8.5-nightly')",
55 version_str.trim()
56 ))?;
57 Ok(version_part.trim().to_string())
58}
59
60pub fn detect_lore_version() -> Result<LoreVersionInfo> {
62 let output = Command::new("lore").arg("--version").output().context(
63 "Failed to execute 'lore --version'. \
64 Lore CLI is not installed or not on PATH. \
65 Install it with: nap install lore",
66 )?;
67
68 if !output.status.success() {
69 let stderr = String::from_utf8_lossy(&output.stderr);
70 anyhow::bail!(
71 "lore --version exited with status: {}. stderr: {}",
72 output.status,
73 stderr.trim()
74 );
75 }
76
77 let version_str = String::from_utf8_lossy(&output.stdout);
78 let raw = extract_version_string(&version_str)?;
79 let parsed = parse_lore_version(&version_str)?;
80 Ok(LoreVersionInfo { parsed, raw })
81}
82
83pub fn detect_loreserver_version() -> Result<LoreVersionInfo> {
85 let output = Command::new("loreserver")
86 .arg("--version")
87 .output()
88 .context(
89 "Failed to execute 'loreserver --version'. \
90 Lore server is not installed or not on PATH. \
91 Install it with: nap install lore",
92 )?;
93
94 if !output.status.success() {
95 let stderr = String::from_utf8_lossy(&output.stderr);
96 anyhow::bail!(
97 "loreserver --version exited with status: {}. stderr: {}",
98 output.status,
99 stderr.trim()
100 );
101 }
102
103 let version_str = String::from_utf8_lossy(&output.stdout);
104 let raw = extract_version_string(&version_str)?;
105 let parsed = parse_lore_version(&version_str)?;
106 Ok(LoreVersionInfo { parsed, raw })
107}
108
109fn parse_lore_version(version_str: &str) -> Result<Version> {
117 let version_part = version_str.split_whitespace().nth(1).context(format!(
119 "Failed to parse Lore version string '{}'. \
120 Expected format: 'lore <version>' (e.g., 'lore 0.8.5-nightly')",
121 version_str.trim()
122 ))?;
123
124 let version_for_semver = version_part.trim_end_matches("-nightly");
126
127 Version::parse(version_for_semver).context(format!(
128 "Failed to parse '{}' as semver version. \
129 Lore version string may be in an unexpected format.",
130 version_for_semver
131 ))
132}
133
134pub fn check_lore_compatibility(installed: &LoreVersionInfo) -> Result<bool> {
141 Ok(installed.raw == 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::warn!("Failed to detect Lore CLI version: {}", e);
152 None
153 }
154 };
155
156 let server_version = match detect_loreserver_version() {
157 Ok(v) => Some(v),
158 Err(e) => {
159 tracing::warn!("Failed to detect Lore server version: {}", 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!(
252 extract_version_string("lore 0.8.5-nightly").unwrap(),
253 "0.8.5-nightly"
254 );
255 assert_eq!(
256 extract_version_string("loreserver 0.8.5-nightly").unwrap(),
257 "0.8.5-nightly"
258 );
259 assert_eq!(extract_version_string("lore 0.8.5\n").unwrap(), "0.8.5");
260 }
261
262 #[test]
263 fn test_extract_version_string_failure() {
264 assert!(extract_version_string("lore").is_err());
266 assert!(extract_version_string("").is_err());
268 }
269
270 #[test]
271 fn test_parse_lore_version() {
272 let version_str = "lore 0.8.5-nightly";
273 let version = parse_lore_version(version_str).unwrap();
274 assert_eq!(version.major, 0);
275 assert_eq!(version.minor, 8);
276 assert_eq!(version.patch, 5);
277 }
278
279 #[test]
280 fn test_parse_loreserver_version() {
281 let version_str = "loreserver 0.8.5-nightly";
282 let version = parse_lore_version(version_str).unwrap();
283 assert_eq!(version.major, 0);
284 assert_eq!(version.minor, 8);
285 assert_eq!(version.patch, 5);
286 }
287
288 #[test]
289 fn test_compatibility_exact_match() {
290 let installed = LoreVersionInfo {
291 parsed: Version::new(0, 8, 5),
292 raw: "0.8.5-nightly".to_string(),
293 };
294 assert!(check_lore_compatibility(&installed).unwrap());
295 }
296
297 #[test]
298 fn test_compatibility_rejects_bare_version() {
299 let installed = LoreVersionInfo {
301 parsed: Version::new(0, 8, 5),
302 raw: "0.8.5".to_string(),
303 };
304 assert!(!check_lore_compatibility(&installed).unwrap());
305 }
306
307 #[test]
308 fn test_compatibility_rejects_wrong_channel() {
309 let installed = LoreVersionInfo {
311 parsed: Version::new(0, 8, 5),
312 raw: "0.8.5-stable".to_string(),
313 };
314 assert!(!check_lore_compatibility(&installed).unwrap());
315 }
316
317 #[test]
318 fn test_compatibility_rejects_wrong_version() {
319 let installed = LoreVersionInfo {
320 parsed: Version::new(0, 7, 0),
321 raw: "0.7.0-nightly".to_string(),
322 };
323 assert!(!check_lore_compatibility(&installed).unwrap());
324 }
325
326 #[test]
327 fn test_installation_status_message() {
328 let status = LoreInstallationStatus {
329 cli_installed: false,
330 cli_version: None,
331 cli_compatible: false,
332 server_installed: false,
333 server_version: None,
334 server_compatible: false,
335 pinned_version: "0.8.5-nightly".to_string(),
336 };
337
338 let message = status.status_message();
339 assert!(message.contains("Lore CLI is not installed"));
340 assert!(message.contains("Lore server is not installed"));
341 }
342
343 #[test]
344 fn test_installation_status_message_incompatible() {
345 let status = LoreInstallationStatus {
346 cli_installed: true,
347 cli_version: Some(LoreVersionInfo {
348 parsed: Version::new(0, 8, 5),
349 raw: "0.8.5-stable".to_string(),
350 }),
351 cli_compatible: false,
352 server_installed: true,
353 server_version: Some(LoreVersionInfo {
354 parsed: Version::new(0, 8, 5),
355 raw: "0.8.5-stable".to_string(),
356 }),
357 server_compatible: false,
358 pinned_version: "0.8.5-nightly".to_string(),
359 };
360
361 let message = status.status_message();
362 assert!(message.contains("'0.8.5-stable'"));
363 assert!(message.contains("'0.8.5-nightly'"));
364 assert!(!status.is_fully_compatible());
365 }
366
367 #[test]
368 fn test_pinned_version_constant() {
369 assert_eq!(PINNED_LORE_VERSION, "0.8.5-nightly");
373 }
374}