upstream_rs/application/operations/
hooks_operation.rs1#[cfg(unix)]
2use crate::services::integration::{nushell_paths_file_contains_path, render_nushell_paths_file};
3use crate::services::{integration::CompletionManager, storage::config_storage::ConfigStorage};
4#[cfg(unix)]
5use crate::utils::platform::shells::installed_shell_commands;
6use crate::utils::static_paths::UpstreamPaths;
7#[cfg(windows)]
8use anyhow::Context;
9use anyhow::Result;
10#[cfg(unix)]
11use std::collections::BTreeSet;
12use std::fs;
13use std::io;
14#[cfg(unix)]
15use std::io::Write;
16#[cfg(unix)]
17use std::path::Path;
18
19#[cfg(unix)]
21const SOURCE_LINE_BASH: &str =
22 "[ -f $HOME/.upstream/metadata/paths.sh ] && source $HOME/.upstream/metadata/paths.sh";
23#[cfg(unix)]
24const SOURCE_LINE_FISH: &str =
25 "test -f $HOME/.upstream/metadata/paths.sh; and source $HOME/.upstream/metadata/paths.sh";
26#[cfg(unix)]
27const SOURCE_LINE_NUSHELL: &str = r#"const upstream_paths_nu = if ("~/.upstream/metadata/paths.nu" | path expand | path exists) { ("~/.upstream/metadata/paths.nu" | path expand) } else { null }; source-env $upstream_paths_nu"#;
28
29pub struct InitCheckReport {
30 pub ok: bool,
31 pub messages: Vec<String>,
32}
33
34#[cfg(windows)]
35fn normalize_windows_path(path: &str) -> String {
36 let mut normalized = path.replace('/', "\\").trim().to_ascii_lowercase();
37 while normalized.ends_with('\\') {
38 normalized.pop();
39 }
40 normalized
41}
42
43pub fn initialize(paths: &UpstreamPaths) -> Result<()> {
44 create_package_dirs(paths)?;
45 create_metadata_files(paths)?;
46 create_default_config_file(paths)?;
47
48 #[cfg(windows)]
49 add_to_windows_path(paths)?;
50
51 #[cfg(unix)]
52 update_shell_profiles(paths)?;
53
54 Ok(())
55}
56
57pub fn purge_data(paths: &UpstreamPaths) -> Result<()> {
58 if paths.dirs.data_dir.exists() {
59 fs::remove_dir_all(&paths.dirs.data_dir)?;
60 }
61 Ok(())
62}
63
64pub fn check(paths: &UpstreamPaths) -> Result<InitCheckReport> {
65 let mut report = InitCheckReport {
66 ok: true,
67 messages: Vec::new(),
68 };
69
70 for (label, path) in [
71 ("config directory", &paths.dirs.config_dir),
72 ("data directory", &paths.dirs.data_dir),
73 ("metadata directory", &paths.dirs.metadata_dir),
74 ("symlinks directory", &paths.integration.symlinks_dir),
75 ("appimages directory", &paths.install.appimages_dir),
76 ("binaries directory", &paths.install.binaries_dir),
77 ("archives directory", &paths.install.archives_dir),
78 ] {
79 if path.exists() {
80 report
81 .messages
82 .push(format!("[OK] {} exists: {}", label, path.display()));
83 } else {
84 report.ok = false;
85 report
86 .messages
87 .push(format!("[FAIL] {} missing: {}", label, path.display()));
88 }
89 }
90
91 let completion_manager = CompletionManager::new(paths);
92 let completion_dirs = completion_manager.installed_shell_completion_dirs();
93 if completion_dirs.is_empty() {
94 report
95 .messages
96 .push("[OK] no supported shells detected for completion installation".to_string());
97 }
98 for (shell, path) in completion_dirs {
99 let label = format!("{shell} completions directory");
100 if path.exists() {
101 report
102 .messages
103 .push(format!("[OK] {} exists: {}", label, path.display()));
104 } else {
105 report.ok = false;
106 report
107 .messages
108 .push(format!("[FAIL] {} missing: {}", label, path.display()));
109 }
110 }
111
112 if paths.config.config_file.exists() {
113 report.messages.push(format!(
114 "[OK] config file exists: {}",
115 paths.config.config_file.display()
116 ));
117 } else {
118 report.ok = false;
119 report.messages.push(format!(
120 "[FAIL] config file missing: {}",
121 paths.config.config_file.display()
122 ));
123 }
124
125 #[cfg(unix)]
126 check_unix_integration(paths, &mut report)?;
127
128 #[cfg(windows)]
129 check_windows_integration(paths, &mut report)?;
130
131 Ok(report)
132}
133
134#[cfg(windows)]
135fn add_to_windows_path(paths: &UpstreamPaths) -> Result<()> {
136 use winreg::RegKey;
137 use winreg::enums::*;
138
139 let hkcu = RegKey::predef(HKEY_CURRENT_USER);
140 let env_key = hkcu
141 .open_subkey_with_flags("Environment", KEY_READ | KEY_WRITE)
142 .context("Failed to open registry key")?;
143
144 let symlinks_path = paths.integration.symlinks_dir.display().to_string();
145 let symlinks_norm = normalize_windows_path(&symlinks_path);
146
147 let current_path: String = env_key.get_value("Path").unwrap_or_else(|_| String::new());
149
150 let path_entries: Vec<&str> = current_path.split(';').collect();
152 if path_entries
153 .iter()
154 .any(|&p| normalize_windows_path(p) == symlinks_norm)
155 {
156 return Ok(()); }
158
159 let new_path = if current_path.is_empty() {
161 symlinks_path
162 } else {
163 format!("{};{}", symlinks_path, current_path)
164 };
165
166 env_key
167 .set_value("Path", &new_path)
168 .context("Failed to set PATH")?;
169
170 broadcast_environment_change();
172
173 Ok(())
174}
175
176#[cfg(windows)]
177fn broadcast_environment_change() {
178 use std::ptr;
179 use winapi::shared::minwindef::LPARAM;
180 use winapi::um::winuser::{
181 HWND_BROADCAST, SMTO_ABORTIFHUNG, SendMessageTimeoutW, WM_SETTINGCHANGE,
182 };
183
184 unsafe {
185 let env_string: Vec<u16> = "Environment\0".encode_utf16().collect();
186 SendMessageTimeoutW(
187 HWND_BROADCAST,
188 WM_SETTINGCHANGE,
189 0,
190 env_string.as_ptr() as LPARAM,
191 SMTO_ABORTIFHUNG,
192 5000,
193 ptr::null_mut(),
194 );
195 }
196}
197
198fn create_package_dirs(paths: &UpstreamPaths) -> io::Result<()> {
199 fs::create_dir_all(&paths.dirs.config_dir)?;
200 fs::create_dir_all(&paths.dirs.data_dir)?;
201 fs::create_dir_all(&paths.dirs.packages_dir)?;
202 fs::create_dir_all(&paths.dirs.cache_dir)?;
203 fs::create_dir_all(&paths.dirs.metadata_dir)?;
204 fs::create_dir_all(&paths.install.appimages_dir)?;
205 fs::create_dir_all(&paths.install.binaries_dir)?;
206 fs::create_dir_all(&paths.install.archives_dir)?;
207 fs::create_dir_all(&paths.install.tmp_dir)?;
208 fs::create_dir_all(&paths.integration.icons_dir)?;
209 fs::create_dir_all(&paths.integration.symlinks_dir)?;
210 for (_shell, dir) in CompletionManager::new(paths).installed_shell_completion_dirs() {
211 fs::create_dir_all(dir)?;
212 }
213 Ok(())
214}
215
216fn create_default_config_file(paths: &UpstreamPaths) -> Result<()> {
217 if paths.config.config_file.exists() {
218 return Ok(());
219 }
220
221 let storage = ConfigStorage::new(&paths.config.config_file)?;
222 storage.save_config()?;
223 Ok(())
224}
225
226#[cfg(unix)]
227fn create_metadata_files(paths: &UpstreamPaths) -> io::Result<()> {
228 if !paths.config.paths_file.exists() {
229 let export_line = format!(
230 r#"export PATH="{}:$PATH""#,
231 paths.integration.symlinks_dir.display()
232 );
233 fs::write(
234 &paths.config.paths_file,
235 format!(
236 "#!/bin/bash\n# Upstream managed PATH additions\n{}\n",
237 export_line
238 ),
239 )?;
240 }
241 if !paths.config.paths_nu_file.exists() {
242 fs::write(
243 &paths.config.paths_nu_file,
244 render_nushell_paths_file(&[paths.integration.symlinks_dir.display().to_string()]),
245 )?;
246 }
247 Ok(())
248}
249
250#[cfg(windows)]
251fn create_metadata_files(_paths: &UpstreamPaths) -> io::Result<()> {
252 Ok(())
254}
255
256#[cfg(unix)]
257fn update_shell_profiles(paths: &UpstreamPaths) -> io::Result<()> {
258 for shell in installed_shell_commands() {
259 match shell.as_str() {
260 "bash" | "sh" => {
261 add_line_to_profile(paths, ".bashrc", SOURCE_LINE_BASH)?;
262 }
263 "zsh" => {
264 add_line_to_profile(paths, ".zshrc", SOURCE_LINE_BASH)?;
265 }
266 "fish" => {
267 let fish_config = Path::new(".config").join("fish").join("config.fish");
268 add_line_to_profile(paths, &fish_config.to_string_lossy(), SOURCE_LINE_FISH)?;
269 }
270 "nu" => {
271 let nushell_config = Path::new(".config").join("nushell").join("config.nu");
272 add_line_to_profile(
273 paths,
274 &nushell_config.to_string_lossy(),
275 SOURCE_LINE_NUSHELL,
276 )?;
277 }
278 _ => {}
279 }
280 }
281 Ok(())
282}
283
284#[cfg(unix)]
285fn check_unix_integration(paths: &UpstreamPaths, report: &mut InitCheckReport) -> io::Result<()> {
286 let expected_line = format!(
287 r#"export PATH="{}:$PATH""#,
288 paths.integration.symlinks_dir.display()
289 );
290
291 if !paths.config.paths_file.exists() {
292 report.ok = false;
293 report.messages.push(format!(
294 "[FAIL] PATH metadata file missing: {}",
295 paths.config.paths_file.display()
296 ));
297 } else {
298 let content = fs::read_to_string(&paths.config.paths_file)?;
299 if content.contains(&expected_line) {
300 report.messages.push(format!(
301 "[OK] PATH metadata file contains symlink export: {}",
302 paths.config.paths_file.display()
303 ));
304 } else {
305 report.ok = false;
306 report.messages.push(format!(
307 "[FAIL] PATH metadata file missing expected export line: {}",
308 paths.config.paths_file.display()
309 ));
310 }
311 }
312
313 let expected_nushell_path = paths.integration.symlinks_dir.display().to_string();
314
315 if !paths.config.paths_nu_file.exists() {
316 report.ok = false;
317 report.messages.push(format!(
318 "[FAIL] Nushell PATH metadata file missing: {}",
319 paths.config.paths_nu_file.display()
320 ));
321 } else {
322 let content = fs::read_to_string(&paths.config.paths_nu_file)?;
323 if nushell_paths_file_contains_path(&content, &expected_nushell_path) {
324 report.messages.push(format!(
325 "[OK] Nushell PATH metadata file contains symlink path: {}",
326 paths.config.paths_nu_file.display()
327 ));
328 } else {
329 report.ok = false;
330 report.messages.push(format!(
331 "[FAIL] Nushell PATH metadata file missing expected symlink path: {}",
332 paths.config.paths_nu_file.display()
333 ));
334 }
335 }
336
337 let mut profiles_to_check: BTreeSet<(String, String)> = BTreeSet::new();
338 for shell in installed_shell_commands() {
339 match shell.as_str() {
340 "bash" | "sh" => {
341 profiles_to_check.insert((".bashrc".to_string(), SOURCE_LINE_BASH.to_string()));
342 }
343 "zsh" => {
344 profiles_to_check.insert((".zshrc".to_string(), SOURCE_LINE_BASH.to_string()));
345 }
346 "fish" => {
347 profiles_to_check.insert((
348 ".config/fish/config.fish".to_string(),
349 SOURCE_LINE_FISH.to_string(),
350 ));
351 }
352 "nu" => {
353 profiles_to_check.insert((
354 ".config/nushell/config.nu".to_string(),
355 SOURCE_LINE_NUSHELL.to_string(),
356 ));
357 }
358 _ => {}
359 }
360 }
361
362 for (profile_rel, expected_line) in profiles_to_check {
363 let profile_path = paths.dirs.user_dir.join(&profile_rel);
364 if !profile_path.exists() {
365 report.ok = false;
366 report.messages.push(format!(
367 "[FAIL] Shell profile missing: {}",
368 profile_path.display()
369 ));
370 continue;
371 }
372
373 let content = fs::read_to_string(&profile_path)?;
374 if content.contains(&expected_line) {
375 report.messages.push(format!(
376 "[OK] Shell profile contains upstream hook: {}",
377 profile_path.display()
378 ));
379 } else {
380 report.ok = false;
381 report.messages.push(format!(
382 "[FAIL] Shell profile missing upstream hook: {}",
383 profile_path.display()
384 ));
385 }
386 }
387
388 Ok(())
389}
390
391#[cfg(unix)]
392fn add_line_to_profile(paths: &UpstreamPaths, relative_path: &str, line: &str) -> io::Result<()> {
393 let profile_path = paths.dirs.user_dir.join(relative_path);
394
395 if let Some(parent) = profile_path.parent() {
397 fs::create_dir_all(parent)?;
398 }
399
400 if profile_path.exists() {
402 let backup_path = profile_path.with_extension("bak");
403 if !backup_path.exists() {
404 fs::copy(&profile_path, &backup_path)?;
405 }
406 }
407
408 if !profile_path.exists() {
409 fs::write(&profile_path, format!("{}\n", line))?;
410 return Ok(());
411 }
412
413 let content = fs::read_to_string(&profile_path)?;
414 if !content.contains(line) {
415 let mut file = fs::OpenOptions::new().append(true).open(&profile_path)?;
416 writeln!(file, "\n{}", line)?;
417 }
418
419 Ok(())
420}
421
422#[cfg(unix)]
423pub fn cleanup(paths: &UpstreamPaths) -> Result<()> {
424 for shell in installed_shell_commands() {
425 let profile = match shell.as_str() {
426 "bash" | "sh" => Some(".bashrc"),
427 "zsh" => Some(".zshrc"),
428 "fish" => Some(".config/fish/config.fish"),
429 "nu" => Some(".config/nushell/config.nu"),
430 _ => None,
431 };
432 if let Some(profile_rel) = profile {
433 let profile_path = paths.dirs.user_dir.join(profile_rel);
434 if !profile_path.exists() {
435 continue;
436 }
437 let mut content = fs::read_to_string(&profile_path)?;
438 content = content
439 .replace(&format!("{}\n", SOURCE_LINE_BASH), "")
440 .replace(SOURCE_LINE_BASH, "")
441 .replace(&format!("{}\n", SOURCE_LINE_FISH), "")
442 .replace(SOURCE_LINE_FISH, "")
443 .replace(&format!("{}\n", SOURCE_LINE_NUSHELL), "")
444 .replace(SOURCE_LINE_NUSHELL, "");
445 fs::write(&profile_path, content)?;
446 }
447 }
448 Ok(())
449}
450
451#[cfg(windows)]
452pub fn cleanup(paths: &UpstreamPaths) -> Result<()> {
453 remove_from_windows_path(paths)
454}
455
456#[cfg(windows)]
457fn remove_from_windows_path(paths: &UpstreamPaths) -> Result<()> {
458 use winreg::RegKey;
459 use winreg::enums::*;
460
461 let hkcu = RegKey::predef(HKEY_CURRENT_USER);
462 let env_key = hkcu
463 .open_subkey_with_flags("Environment", KEY_READ | KEY_WRITE)
464 .context("Failed to open registry key")?;
465
466 let symlinks_path = paths.integration.symlinks_dir.display().to_string();
467 let symlinks_norm = normalize_windows_path(&symlinks_path);
468
469 let current_path: String = env_key.get_value("Path").unwrap_or_else(|_| String::new());
471
472 let path_entries: Vec<&str> = current_path
474 .split(';')
475 .filter(|&p| normalize_windows_path(p) != symlinks_norm)
476 .collect();
477
478 let new_path = path_entries.join(";");
479
480 env_key
481 .set_value("Path", &new_path)
482 .context("Failed to set PATH")?;
483
484 broadcast_environment_change();
486
487 Ok(())
488}
489
490#[cfg(windows)]
491fn check_windows_integration(paths: &UpstreamPaths, report: &mut InitCheckReport) -> Result<()> {
492 use winreg::RegKey;
493 use winreg::enums::*;
494
495 let hkcu = RegKey::predef(HKEY_CURRENT_USER);
496 let env_key = hkcu
497 .open_subkey_with_flags("Environment", KEY_READ)
498 .context("Failed to open PATH")?;
499
500 let symlinks_path = paths.integration.symlinks_dir.display().to_string();
501 let symlinks_norm = normalize_windows_path(&symlinks_path);
502 let current_path: String = env_key.get_value("Path").unwrap_or_else(|_| String::new());
503
504 let in_path = current_path
505 .split(';')
506 .any(|p| normalize_windows_path(p) == symlinks_norm);
507
508 if in_path {
509 report
510 .messages
511 .push("[OK] Windows PATH contains upstream symlinks directory".to_string());
512 } else {
513 report.ok = false;
514 report
515 .messages
516 .push("[FAIL] Windows PATH missing upstream symlinks directory".to_string());
517 }
518
519 Ok(())
520}
521
522#[cfg(test)]
523mod tests {
524 use super::purge_data;
525 use crate::utils::static_paths::{
526 AppDirs, ConfigPaths, InstallPaths, IntegrationPaths, UpstreamPaths,
527 };
528 use std::path::{Path, PathBuf};
529 use std::time::{SystemTime, UNIX_EPOCH};
530 use std::{fs, io};
531
532 fn temp_root(name: &str) -> PathBuf {
533 let nanos = SystemTime::now()
534 .duration_since(UNIX_EPOCH)
535 .map(|d| d.as_nanos())
536 .unwrap_or(0);
537 std::env::temp_dir().join(format!("upstream-init-test-{name}-{nanos}"))
538 }
539
540 fn test_paths(root: &Path) -> UpstreamPaths {
541 let dirs = AppDirs {
542 user_dir: root.to_path_buf(),
543 config_dir: root.join("config"),
544 data_dir: root.join(".upstream"),
545 packages_dir: root.join(".upstream/packages"),
546 cache_dir: root.join(".upstream/cache"),
547 metadata_dir: root.join(".upstream/metadata"),
548 };
549
550 UpstreamPaths {
551 config: ConfigPaths {
552 config_file: dirs.config_dir.join("config.toml"),
553 packages_file: dirs.metadata_dir.join("packages.json"),
554 metadata_file: dirs.metadata_dir.join("metadata.json"),
555 paths_file: dirs.metadata_dir.join("paths.sh"),
556 paths_nu_file: dirs.metadata_dir.join("paths.nu"),
557 },
558 install: InstallPaths {
559 appimages_dir: dirs.packages_dir.join("appimages"),
560 binaries_dir: dirs.packages_dir.join("binaries"),
561 archives_dir: dirs.packages_dir.join("archives"),
562 rollback_dir: dirs.data_dir.join("rollback"),
563 tmp_dir: dirs.data_dir.join("tmp"),
564 },
565 integration: IntegrationPaths {
566 symlinks_dir: dirs.data_dir.join("symlinks"),
567 xdg_applications_dir: dirs.user_dir.join(".local/share/applications"),
568 icons_dir: dirs.data_dir.join("icons"),
569 bash_completions_dir: dirs
570 .user_dir
571 .join(".local/share/bash-completion/completions"),
572 fish_completions_dir: dirs.user_dir.join(".config/fish/completions"),
573 zsh_completions_dir: dirs.user_dir.join(".local/share/zsh/site-functions"),
574 },
575 dirs,
576 }
577 }
578
579 fn cleanup(path: &Path) -> io::Result<()> {
580 if path.exists() {
581 fs::remove_dir_all(path)?;
582 }
583 Ok(())
584 }
585
586 #[cfg(unix)]
587 #[test]
588 fn create_metadata_files_creates_posix_and_nushell_path_files() {
589 let root = temp_root("metadata-files");
590 let paths = test_paths(&root);
591 fs::create_dir_all(&paths.dirs.metadata_dir).expect("create metadata dir");
592
593 super::create_metadata_files(&paths).expect("create metadata files");
594
595 let posix_content = fs::read_to_string(&paths.config.paths_file).expect("read paths.sh");
596 assert!(posix_content.contains("export PATH="));
597 assert!(posix_content.contains(&paths.integration.symlinks_dir.display().to_string()));
598
599 let nushell_content =
600 fs::read_to_string(&paths.config.paths_nu_file).expect("read paths.nu");
601 assert!(nushell_content.contains("let upstream_paths = ["));
602 assert!(nushell_content.contains("$env.PATH = ($upstream_paths ++ $env.PATH)"));
603 assert!(nushell_content.contains(&paths.integration.symlinks_dir.display().to_string()));
604
605 cleanup(&root).expect("cleanup");
606 }
607
608 #[test]
609 fn purge_data_removes_data_dir_but_keeps_config_dir() {
610 let root = temp_root("purge");
611 let paths = test_paths(&root);
612 fs::create_dir_all(&paths.dirs.data_dir).expect("create data dir");
613 fs::create_dir_all(&paths.dirs.config_dir).expect("create config dir");
614 fs::write(paths.dirs.data_dir.join("data"), b"data").expect("write data");
615 fs::write(paths.dirs.config_dir.join("config.toml"), b"").expect("write config");
616
617 purge_data(&paths).expect("purge data");
618
619 assert!(!paths.dirs.data_dir.exists());
620 assert!(paths.dirs.config_dir.exists());
621
622 cleanup(&root).expect("cleanup");
623 }
624}