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(
82 script_path: &Path,
83 channel: UpdateChannel,
84) -> Result<(), UpdateLaunchError> {
85 std::process::Command::new("powershell.exe")
86 .arg("-NoProfile")
87 .arg("-ExecutionPolicy")
88 .arg("Bypass")
89 .arg("-File")
90 .arg(script_path)
91 .arg("-Channel")
92 .arg(channel.as_arg())
93 .spawn()
94 .map_err(|error| {
95 UpdateLaunchError::LaunchFailed(format!(
96 "failed to launch updater script '{}': {error}",
97 script_path.display()
98 ))
99 })?;
100 Ok(())
101}
102
103#[cfg(not(target_os = "windows"))]
104fn launch_updater_script(
105 _script_path: &Path,
106 _channel: UpdateChannel,
107) -> Result<(), UpdateLaunchError> {
108 Err(UpdateLaunchError::UnsupportedPlatform)
109}
110
111fn updater_script_candidates(exe_path: &Path, cwd: &Path) -> Vec<PathBuf> {
112 let mut candidates = Vec::new();
113
114 if let Some(exe_dir) = exe_path.parent() {
115 if let Some(install_root) = exe_dir.parent() {
116 push_unique(
117 &mut candidates,
118 install_root.join(INSTALLED_UPDATER_RELATIVE_PATH),
119 );
120 push_unique(
121 &mut candidates,
122 install_root.join(INSTALLED_UPDATER_FALLBACK_NAME),
123 );
124 }
125 collect_ancestor_candidates(exe_dir, &mut candidates, DEV_UPDATER_RELATIVE_PATH);
126 }
127
128 collect_ancestor_candidates(cwd, &mut candidates, DEV_UPDATER_RELATIVE_PATH);
129
130 candidates
131}
132
133fn collect_ancestor_candidates(base: &Path, out: &mut Vec<PathBuf>, relative: &str) {
134 for ancestor in base.ancestors().take(MAX_ANCESTOR_SCAN_DEPTH) {
135 push_unique(out, ancestor.join(relative));
136 }
137}
138
139fn push_unique(paths: &mut Vec<PathBuf>, candidate: PathBuf) {
140 if !paths.iter().any(|existing| existing == &candidate) {
141 paths.push(candidate);
142 }
143}
144
145#[cfg(test)]
146mod tests {
147 use super::{
148 updater_script_candidates, DEV_UPDATER_RELATIVE_PATH, INSTALLED_UPDATER_RELATIVE_PATH,
149 };
150
151 #[test]
152 fn updater_candidates_prefer_installed_layout_before_repo_fallback() {
153 let root =
154 std::env::temp_dir().join(format!("nex-updater-installed-{}", std::process::id()));
155 let exe_path = root.join("bin/nex.exe");
156 let cwd = root.clone();
157
158 let candidates = updater_script_candidates(&exe_path, &cwd);
159
160 assert_eq!(candidates[0], root.join(INSTALLED_UPDATER_RELATIVE_PATH));
161 assert_eq!(candidates[1], root.join("update-nex.ps1"));
162 }
163
164 #[test]
165 fn updater_candidates_include_repo_style_script_lookup() {
166 let root = std::env::temp_dir().join(format!("nex-updater-repo-{}", std::process::id()));
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!("nex-updater-dedupe-{}", std::process::id()));
181 let exe_path = repo.join("target/debug/nex.exe");
182 let cwd = repo.join("target/debug");
183
184 let candidates = updater_script_candidates(&exe_path, &cwd);
185 let unique = candidates.iter().collect::<std::collections::BTreeSet<_>>();
186
187 assert_eq!(candidates.len(), unique.len());
188 }
189}