Skip to main content

upstream_rs/application/operations/
hooks_operation.rs

1#[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// Unix shell source lines
20#[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    // Get current PATH
148    let current_path: String = env_key.get_value("Path").unwrap_or_else(|_| String::new());
149
150    // Check if our path is already in PATH
151    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(()); // Already in PATH
157    }
158
159    // Add our path to the beginning
160    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 WM_SETTINGCHANGE to notify other applications
171    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    // On Windows, we use registry-based PATH, so no metadata files needed
253    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    // Ensure parent directory exists
396    if let Some(parent) = profile_path.parent() {
397        fs::create_dir_all(parent)?;
398    }
399
400    // Backup original file
401    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    // Get current PATH
470    let current_path: String = env_key.get_value("Path").unwrap_or_else(|_| String::new());
471
472    // Remove our path from PATH
473    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 WM_SETTINGCHANGE to notify other applications
485    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}