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 against
9//! [`PINNED_LORE_VERSION`].
10
11use anyhow::{Context, Result};
12use semver::Version;
13use std::process::Command;
14
15/// Pinned Lore version that NAP SDK requires.
16///
17/// During initialization NAP verifies that the installed `lore` and
18/// `loreserver` binaries report **exactly** this version string.
19pub const PINNED_LORE_VERSION: &str = "0.8.4";
20
21// ── Detected version info ───────────────────────────────────────────────
22
23/// A detected Lore version with both parsed semver and raw string forms.
24///
25/// The `raw` field preserves the full version string reported by the CLI
26/// so that compatibility checks can enforce an exact match — not just
27/// major.minor.patch.
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct LoreVersionInfo {
30    /// Parsed semver (e.g. `0.8.4`).  The nightly suffix is stripped here
31    /// because `semver::Version` has no concept of release channels.
32    pub parsed: Version,
33    /// Raw version string exactly as reported by the binary
34    /// (e.g. `"0.8.4"`).
35    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
44// ── Version detection ───────────────────────────────────────────────────
45
46/// Extract the raw version token from CLI output.
47///
48/// Given `"lore 0.8.4\n"`, returns `"0.8.4"`.
49fn 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
58/// Detect the installed Lore CLI version
59pub 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
81/// Detect the installed Lore server version
82pub 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
107// ── Version parsing ─────────────────────────────────────────────────────
108
109/// Parse Lore version string into `semver::Version`.
110///
111/// Strips the nightly/release suffix before parsing because
112/// `semver::Version` does not model release channels.  Use
113/// [`extract_version_string`] when you need the full, unparsed token.
114fn parse_lore_version(version_str: &str) -> Result<Version> {
115    // Lore version format: "lore 0.8.4" or "loreserver 0.8.4"
116    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    // Handle nightly versions by stripping the suffix for semver parsing
123    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
132// ── Compatibility gate ──────────────────────────────────────────────────
133
134/// Check if the installed Lore version **exactly** matches the pinned version.
135///
136/// Build metadata after `+` (e.g. `0.8.4+283`) is stripped before
137/// comparison per the semver spec. Pre-release tags like `-nightly` or
138/// `-stable` are **not** stripped and will cause a mismatch.
139pub 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
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::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// ── 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!(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        // Single-word input has no second token → should fail
259        assert!(extract_version_string("lore").is_err());
260        // Empty string → should fail
261        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        // Nightly suffix is stripped for semver parsing
276        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        // "0.8.4+283" must match pinned "0.8.4" — build metadata is
304        // ignored per the semver specification.
305        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        // "0.8.4-nightly" must NOT match pinned "0.8.4"
315        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        // This test documents the contract: the pinned version must be
384        // "0.8.4".  If you intentionally change it, update this test and
385        // the integration test as well.
386        assert_eq!(PINNED_LORE_VERSION, "0.8.4");
387    }
388}