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
8use anyhow::{Context, Result};
9use semver::Version;
10use std::process::Command;
11
12/// Pinned Lore version that NAP SDK requires
13pub const PINNED_LORE_VERSION: &str = "0.8.5-nightly";
14
15/// Detect the installed Lore CLI version
16pub fn detect_lore_version() -> Result<Version> {
17    let output = Command::new("lore").arg("--version").output().context(
18        "Failed to execute 'lore --version'. \
19             Lore CLI is not installed or not on PATH. \
20             Install it with: nap install lore",
21    )?;
22
23    if !output.status.success() {
24        let stderr = String::from_utf8_lossy(&output.stderr);
25        anyhow::bail!(
26            "lore --version exited with status: {}. stderr: {}",
27            output.status,
28            stderr.trim()
29        );
30    }
31
32    let version_str = String::from_utf8_lossy(&output.stdout);
33    parse_lore_version(&version_str)
34}
35
36/// Detect the installed Lore server version
37pub fn detect_loreserver_version() -> Result<Version> {
38    let output = Command::new("loreserver")
39        .arg("--version")
40        .output()
41        .context(
42            "Failed to execute 'loreserver --version'. \
43             Lore server is not installed or not on PATH. \
44             Install it with: nap install lore",
45        )?;
46
47    if !output.status.success() {
48        let stderr = String::from_utf8_lossy(&output.stderr);
49        anyhow::bail!(
50            "loreserver --version exited with status: {}. stderr: {}",
51            output.status,
52            stderr.trim()
53        );
54    }
55
56    let version_str = String::from_utf8_lossy(&output.stdout);
57    parse_lore_version(&version_str)
58}
59
60/// Parse Lore version string into semver::Version
61fn parse_lore_version(version_str: &str) -> Result<Version> {
62    // Lore version format: "lore 0.8.5-nightly" or "loreserver 0.8.5-nightly"
63    let version_part = version_str.split_whitespace().nth(1).context(format!(
64        "Failed to parse Lore version string '{}'. \
65             Expected format: 'lore <version>' (e.g., 'lore 0.8.5-nightly')",
66        version_str.trim()
67    ))?;
68
69    // Handle nightly versions by stripping the suffix for comparison
70    let version_for_semver = version_part.trim_end_matches("-nightly");
71
72    Version::parse(version_for_semver).context(format!(
73        "Failed to parse '{}' as semver version. \
74             Lore version string may be in an unexpected format.",
75        version_for_semver
76    ))
77}
78
79/// Check if installed Lore version is compatible with pinned version
80pub fn check_lore_compatibility(installed_version: &Version) -> Result<bool> {
81    let pinned = Version::parse(PINNED_LORE_VERSION.trim_end_matches("-nightly"))
82        .context("Failed to parse pinned Lore version")?;
83
84    // For now, require exact match on major.minor.patch
85    // Nightly suffix is ignored for comparison
86    let installed_clean = Version::new(
87        installed_version.major,
88        installed_version.minor,
89        installed_version.patch,
90    );
91
92    let pinned_clean = Version::new(pinned.major, pinned.minor, pinned.patch);
93
94    Ok(installed_clean == pinned_clean)
95}
96
97/// Verify Lore installation and compatibility
98pub fn verify_lore_installation() -> Result<LoreInstallationStatus> {
99    let cli_version = match detect_lore_version() {
100        Ok(v) => Some(v),
101        Err(e) => {
102            tracing::warn!("Failed to detect Lore CLI version: {}", e);
103            None
104        }
105    };
106
107    let server_version = match detect_loreserver_version() {
108        Ok(v) => Some(v),
109        Err(e) => {
110            tracing::warn!("Failed to detect Lore server version: {}", e);
111            None
112        }
113    };
114
115    let cli_compatible = cli_version
116        .as_ref()
117        .map(|v| check_lore_compatibility(v).unwrap_or(false))
118        .unwrap_or(false);
119
120    let server_compatible = server_version
121        .as_ref()
122        .map(|v| check_lore_compatibility(v).unwrap_or(false))
123        .unwrap_or(false);
124
125    Ok(LoreInstallationStatus {
126        cli_installed: cli_version.is_some(),
127        cli_version,
128        cli_compatible,
129        server_installed: server_version.is_some(),
130        server_version,
131        server_compatible,
132        pinned_version: PINNED_LORE_VERSION.to_string(),
133    })
134}
135
136/// Status of Lore installation
137#[derive(Debug, Clone)]
138pub struct LoreInstallationStatus {
139    pub cli_installed: bool,
140    pub cli_version: Option<Version>,
141    pub cli_compatible: bool,
142    pub server_installed: bool,
143    pub server_version: Option<Version>,
144    pub server_compatible: bool,
145    pub pinned_version: String,
146}
147
148impl LoreInstallationStatus {
149    /// Check if installation is fully compatible
150    pub fn is_fully_compatible(&self) -> bool {
151        self.cli_installed && self.cli_compatible && self.server_installed && self.server_compatible
152    }
153
154    /// Get a human-readable status message
155    pub fn status_message(&self) -> String {
156        let mut messages = vec![];
157
158        if !self.cli_installed {
159            messages.push("Lore CLI is not installed".to_string());
160        } else if !self.cli_compatible {
161            messages.push(format!(
162                "Lore CLI version {:?} is incompatible with pinned version {}",
163                self.cli_version, self.pinned_version
164            ));
165        }
166
167        if !self.server_installed {
168            messages.push("Lore server is not installed".to_string());
169        } else if !self.server_compatible {
170            messages.push(format!(
171                "Lore server version {:?} is incompatible with pinned version {}",
172                self.server_version, self.pinned_version
173            ));
174        }
175
176        if messages.is_empty() {
177            "Lore installation is compatible".to_string()
178        } else {
179            messages.join("; ")
180        }
181    }
182}
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187
188    #[test]
189    fn test_parse_lore_version() {
190        let version_str = "lore 0.8.5-nightly";
191        let version = parse_lore_version(version_str).unwrap();
192        assert_eq!(version.major, 0);
193        assert_eq!(version.minor, 8);
194        assert_eq!(version.patch, 5);
195    }
196
197    #[test]
198    fn test_parse_loreserver_version() {
199        let version_str = "loreserver 0.8.5-nightly";
200        let version = parse_lore_version(version_str).unwrap();
201        assert_eq!(version.major, 0);
202        assert_eq!(version.minor, 8);
203        assert_eq!(version.patch, 5);
204    }
205
206    #[test]
207    fn test_compatibility_check() {
208        let installed = Version::new(0, 8, 5);
209        assert!(check_lore_compatibility(&installed).unwrap());
210
211        let incompatible = Version::new(0, 7, 0);
212        assert!(!check_lore_compatibility(&incompatible).unwrap());
213    }
214
215    #[test]
216    fn test_installation_status_message() {
217        let status = LoreInstallationStatus {
218            cli_installed: false,
219            cli_version: None,
220            cli_compatible: false,
221            server_installed: false,
222            server_version: None,
223            server_compatible: false,
224            pinned_version: "0.8.5-nightly".to_string(),
225        };
226
227        let message = status.status_message();
228        assert!(message.contains("Lore CLI is not installed"));
229        assert!(message.contains("Lore server is not installed"));
230    }
231}