Skip to main content

nap_core/server/
version.rs

1// SPDX-FileCopyrightText: 2026 Digital Creations
2// SPDX-License-Identifier: MIT
3//! Lore version detection and compatibility checking
4//!
5//! Provides utilities to detect installed Lore CLI/server versions and
6//! verify compatibility with the SDK's pinned version.
7//!
8//! NAP requires an **exact match** of the full version string (including the
9//! nightly/release suffix) against [`PINNED_LORE_VERSION`].  A bare `0.8.5`
10//! or an alternate channel like `0.8.5-stable` will **not** pass the
11//! compatibility gate.
12
13use anyhow::{Context, Result};
14use semver::Version;
15use std::process::Command;
16
17/// Pinned Lore version that NAP SDK requires.
18///
19/// During initialization NAP verifies that the installed `loreserver`
20/// reports **exactly** this string (e.g. `0.8.5-nightly`).
21pub const PINNED_LORE_VERSION: &str = "0.8.5-nightly";
22
23// ── Detected version info ───────────────────────────────────────────────
24
25/// A detected Lore version with both parsed semver and raw string forms.
26///
27/// The `raw` field preserves the full version string reported by the CLI
28/// (e.g. `"0.8.5-nightly"`) so that compatibility checks can enforce an
29/// exact match — not just major.minor.patch.
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct LoreVersionInfo {
32    /// Parsed semver (e.g. `0.8.5`).  The nightly suffix is stripped here
33    /// because `semver::Version` has no concept of release channels.
34    pub parsed: Version,
35    /// Raw version string exactly as reported by the binary
36    /// (e.g. `"0.8.5-nightly"`).
37    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
46// ── Version detection ───────────────────────────────────────────────────
47
48/// Extract the raw version token from CLI output.
49///
50/// Given `"lore 0.8.5-nightly\n"`, returns `"0.8.5-nightly"`.
51fn 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
60/// Detect the installed Lore CLI version
61pub 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
83/// Detect the installed Lore server version
84pub 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
109// ── Version parsing ─────────────────────────────────────────────────────
110
111/// Parse Lore version string into `semver::Version`.
112///
113/// Strips the nightly/release suffix before parsing because
114/// `semver::Version` does not model release channels.  Use
115/// [`extract_version_string`] when you need the full, unparsed token.
116fn parse_lore_version(version_str: &str) -> Result<Version> {
117    // Lore version format: "lore 0.8.5-nightly" or "loreserver 0.8.5-nightly"
118    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    // Handle nightly versions by stripping the suffix for semver parsing
125    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
134// ── Compatibility gate ──────────────────────────────────────────────────
135
136/// Check if the installed Lore version **exactly** matches the pinned version.
137///
138/// The comparison is a strict string equality of the raw version tokens,
139/// so `"0.8.5-nightly"` matches but `"0.8.5"` or `"0.8.5-stable"` does not.
140pub fn check_lore_compatibility(installed: &LoreVersionInfo) -> Result<bool> {
141    Ok(installed.raw == PINNED_LORE_VERSION)
142}
143
144// ── Full installation verification ──────────────────────────────────────
145
146/// Verify Lore installation and compatibility
147pub 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// ── Installation status ─────────────────────────────────────────────────
186
187/// Status of Lore installation
188#[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    /// Check if installation is fully compatible
201    pub fn is_fully_compatible(&self) -> bool {
202        self.cli_installed && self.cli_compatible && self.server_installed && self.server_compatible
203    }
204
205    /// Get a human-readable status message
206    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// ── Unit tests ──────────────────────────────────────────────────────────
244
245#[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        // Single-word input has no second token → should fail
265        assert!(extract_version_string("lore").is_err());
266        // Empty string → should fail
267        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        // Bare "0.8.5" without the "-nightly" suffix must NOT be compatible.
300        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        // Same major.minor.patch but "-stable" instead of "-nightly"
310        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        // This test documents the contract: the pinned version must be
370        // "0.8.5-nightly".  If you intentionally change it, update this
371        // test and the integration test as well.
372        assert_eq!(PINNED_LORE_VERSION, "0.8.5-nightly");
373    }
374}