Skip to main content

nex_core/
updater.rs

1use std::fmt::{Display, Formatter};
2use std::path::{Path, PathBuf};
3
4const INSTALLED_UPDATER_RELATIVE_PATH: &str = "scripts/update-nex.ps1";
5const INSTALLED_UPDATER_FALLBACK_NAME: &str = "update-nex.ps1";
6const DEV_UPDATER_RELATIVE_PATH: &str = "scripts/windows/update-nex.ps1";
7const MAX_ANCESTOR_SCAN_DEPTH: usize = 10;
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum UpdateChannel {
11    Stable,
12    Beta,
13}
14
15impl UpdateChannel {
16    pub fn as_arg(self) -> &'static str {
17        match self {
18            Self::Stable => "stable",
19            Self::Beta => "beta",
20        }
21    }
22}
23
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub enum UpdateLaunchError {
26    UnsupportedPlatform,
27    EnvironmentUnavailable(String),
28    ScriptNotFound { checked_paths: Vec<PathBuf> },
29    LaunchFailed(String),
30}
31
32impl Display for UpdateLaunchError {
33    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
34        match self {
35            Self::UnsupportedPlatform => write!(f, "updater is only available on Windows"),
36            Self::EnvironmentUnavailable(message) => write!(f, "{message}"),
37            Self::ScriptNotFound { checked_paths } => {
38                if checked_paths.is_empty() {
39                    write!(f, "update script not found")
40                } else {
41                    let joined = checked_paths
42                        .iter()
43                        .map(|path| path.display().to_string())
44                        .collect::<Vec<_>>()
45                        .join(", ");
46                    write!(f, "update script not found (checked: {joined})")
47                }
48            }
49            Self::LaunchFailed(message) => write!(f, "{message}"),
50        }
51    }
52}
53
54impl std::error::Error for UpdateLaunchError {}
55
56pub fn launch_updater(channel: UpdateChannel) -> Result<PathBuf, UpdateLaunchError> {
57    let exe_path = std::env::current_exe().map_err(|error| {
58        UpdateLaunchError::EnvironmentUnavailable(format!(
59            "could not resolve current executable path: {error}"
60        ))
61    })?;
62    let cwd = std::env::current_dir().map_err(|error| {
63        UpdateLaunchError::EnvironmentUnavailable(format!(
64            "could not resolve current working directory: {error}"
65        ))
66    })?;
67    let checked_paths = updater_script_candidates(&exe_path, &cwd);
68    let script_path = checked_paths
69        .iter()
70        .find(|candidate| candidate.exists())
71        .cloned()
72        .ok_or_else(|| UpdateLaunchError::ScriptNotFound {
73            checked_paths: checked_paths.clone(),
74        })?;
75
76    launch_updater_script(script_path.as_path(), channel)?;
77    Ok(script_path)
78}
79
80#[cfg(target_os = "windows")]
81fn launch_updater_script(script_path: &Path, channel: UpdateChannel) -> Result<(), UpdateLaunchError> {
82    std::process::Command::new("powershell.exe")
83        .arg("-NoProfile")
84        .arg("-ExecutionPolicy")
85        .arg("Bypass")
86        .arg("-File")
87        .arg(script_path)
88        .arg("-Channel")
89        .arg(channel.as_arg())
90        .spawn()
91        .map_err(|error| {
92            UpdateLaunchError::LaunchFailed(format!(
93                "failed to launch updater script '{}': {error}",
94                script_path.display()
95            ))
96        })?;
97    Ok(())
98}
99
100#[cfg(not(target_os = "windows"))]
101fn launch_updater_script(
102    _script_path: &Path,
103    _channel: UpdateChannel,
104) -> Result<(), UpdateLaunchError> {
105    Err(UpdateLaunchError::UnsupportedPlatform)
106}
107
108fn updater_script_candidates(exe_path: &Path, cwd: &Path) -> Vec<PathBuf> {
109    let mut candidates = Vec::new();
110
111    if let Some(exe_dir) = exe_path.parent() {
112        if let Some(install_root) = exe_dir.parent() {
113            push_unique(
114                &mut candidates,
115                install_root.join(INSTALLED_UPDATER_RELATIVE_PATH),
116            );
117            push_unique(
118                &mut candidates,
119                install_root.join(INSTALLED_UPDATER_FALLBACK_NAME),
120            );
121        }
122        collect_ancestor_candidates(exe_dir, &mut candidates, DEV_UPDATER_RELATIVE_PATH);
123    }
124
125    collect_ancestor_candidates(cwd, &mut candidates, DEV_UPDATER_RELATIVE_PATH);
126
127    candidates
128}
129
130fn collect_ancestor_candidates(base: &Path, out: &mut Vec<PathBuf>, relative: &str) {
131    for ancestor in base.ancestors().take(MAX_ANCESTOR_SCAN_DEPTH) {
132        push_unique(out, ancestor.join(relative));
133    }
134}
135
136fn push_unique(paths: &mut Vec<PathBuf>, candidate: PathBuf) {
137    if !paths.iter().any(|existing| existing == &candidate) {
138        paths.push(candidate);
139    }
140}
141
142#[cfg(test)]
143mod tests {
144    use super::{updater_script_candidates, DEV_UPDATER_RELATIVE_PATH, INSTALLED_UPDATER_RELATIVE_PATH};
145
146    #[test]
147    fn updater_candidates_prefer_installed_layout_before_repo_fallback() {
148        let root = std::env::temp_dir().join(format!(
149            "nex-updater-installed-{}",
150            std::process::id()
151        ));
152        let exe_path = root.join("bin/nex.exe");
153        let cwd = root.clone();
154
155        let candidates = updater_script_candidates(&exe_path, &cwd);
156
157        assert_eq!(candidates[0], root.join(INSTALLED_UPDATER_RELATIVE_PATH));
158        assert_eq!(candidates[1], root.join("update-nex.ps1"));
159    }
160
161    #[test]
162    fn updater_candidates_include_repo_style_script_lookup() {
163        let root = std::env::temp_dir().join(format!(
164            "nex-updater-repo-{}",
165            std::process::id()
166        ));
167        let repo = root.join("repo");
168        let exe_path = repo.join("target/debug/nex.exe");
169        let cwd = repo.join("apps/core");
170
171        let candidates = updater_script_candidates(&exe_path, &cwd);
172
173        assert!(candidates
174            .iter()
175            .any(|candidate| candidate == &repo.join(DEV_UPDATER_RELATIVE_PATH)));
176    }
177
178    #[test]
179    fn updater_candidates_are_deduplicated() {
180        let repo = std::env::temp_dir().join(format!(
181            "nex-updater-dedupe-{}",
182            std::process::id()
183        ));
184        let exe_path = repo.join("target/debug/nex.exe");
185        let cwd = repo.join("target/debug");
186
187        let candidates = updater_script_candidates(&exe_path, &cwd);
188        let unique = candidates.iter().collect::<std::collections::BTreeSet<_>>();
189
190        assert_eq!(candidates.len(), unique.len());
191    }
192}