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/// Release repository containing the Portals authentication-capable Lore
22/// client. Never fall back to the unaffiliated upstream release at runtime.
23pub const PINNED_LORE_REPOSITORY: &str = "portalshq/lore";
24
25/// SHA-256 of `scripts/install.sh` at the pinned Lore release tag. This makes
26/// `nap install lore` reject a moved tag or compromised download before
27/// executing it.
28pub const PINNED_LORE_INSTALLER_SHA256: &str =
29    "ed2254daa16fe9eee9ef457b4059ce4c0d953c14c9660697746d796df729b435";
30
31/// Digest and Sigstore bundle for the Lore release's binary checksum
32/// manifest. Empty means this Nap source is not eligible for a secure cloud
33/// release even though local development can still use the pinned installer.
34pub 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// ── Detected version info ───────────────────────────────────────────────
39
40/// A detected Lore version with both parsed semver and raw string forms.
41///
42/// The `raw` field preserves the full version string reported by the CLI
43/// so that compatibility checks can enforce an exact match — not just
44/// major.minor.patch.
45#[derive(Debug, Clone, PartialEq, Eq)]
46pub struct LoreVersionInfo {
47    /// Parsed semver (e.g. `0.8.4`).  The nightly suffix is stripped here
48    /// because `semver::Version` has no concept of release channels.
49    pub parsed: Version,
50    /// Raw version string exactly as reported by the binary
51    /// (e.g. `"0.8.4"`).
52    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
61// ── Version detection ───────────────────────────────────────────────────
62
63/// Extract the raw version token from CLI output.
64///
65/// Given `"lore 0.8.4\n"`, returns `"0.8.4"`.
66fn 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
75/// Detect the installed Lore CLI version
76pub 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
98/// Detect the installed Lore server version
99pub 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
124// ── Version parsing ─────────────────────────────────────────────────────
125
126/// Parse Lore version string into `semver::Version`.
127///
128/// Strips the nightly/release suffix before parsing because
129/// `semver::Version` does not model release channels.  Use
130/// [`extract_version_string`] when you need the full, unparsed token.
131fn parse_lore_version(version_str: &str) -> Result<Version> {
132    // Lore version format: "lore 0.8.4" or "loreserver 0.8.4"
133    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    // Handle nightly versions by stripping the suffix for semver parsing
140    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
149// ── Compatibility gate ──────────────────────────────────────────────────
150
151/// Check if the installed Lore version **exactly** matches the pinned version.
152///
153/// Build metadata after `+` (e.g. `0.8.4+283`) is stripped before
154/// comparison per the semver spec. Pre-release tags like `-nightly` or
155/// `-stable` are **not** stripped and will cause a mismatch.
156pub 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
161// ── Full installation verification ──────────────────────────────────────
162
163/// Verify Lore installation and compatibility
164pub 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// ── Installation status ─────────────────────────────────────────────────
203
204/// Status of Lore installation
205#[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    /// Check if installation is fully compatible
218    pub fn is_fully_compatible(&self) -> bool {
219        self.cli_installed && self.cli_compatible && self.server_installed && self.server_compatible
220    }
221
222    /// Get a human-readable status message
223    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// ── Unit tests ──────────────────────────────────────────────────────────
261
262#[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        // Single-word input has no second token → should fail
276        assert!(extract_version_string("lore").is_err());
277        // Empty string → should fail
278        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        // Nightly suffix is stripped for semver parsing
293        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        // "0.8.4+283" must match pinned "0.8.4" — build metadata is
321        // ignored per the semver specification.
322        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        // "0.8.4-nightly" must NOT match pinned "0.8.4"
332        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        // This test documents the contract: the pinned version must be
401        // "0.8.4".  If you intentionally change it, update this test and
402        // the integration test as well.
403        assert_eq!(PINNED_LORE_VERSION, "0.8.4");
404    }
405}