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}