upstream-rs 2.5.0

Fetch package updates directly from the source.
Documentation
use std::collections::HashSet;
use std::fs;
use std::path::{Path, PathBuf};

use crate::{
    models::upstream::{AppConfig, Package},
    services::storage::config_storage::ConfigStorage,
    utils::static_paths::UpstreamPaths,
};

use super::super::{DoctorReport, Level};

const HOOKS_INIT_DIR_HINT: &str =
    "Run `upstream hooks init` to create missing upstream directories and metadata files.";
const MIGRATE_DIR_HINT: &str =
    "Run `upstream migrate` to update local data for the current upstream layout.";

fn required_directory_checks(paths: &UpstreamPaths) -> Vec<(&'static str, &Path)> {
    vec![
        ("data directory", paths.dirs.data_dir.as_path()),
        ("packages directory", paths.dirs.packages_dir.as_path()),
        ("cache directory", paths.dirs.cache_dir.as_path()),
        ("metadata directory", paths.dirs.metadata_dir.as_path()),
        (
            "symlinks directory",
            paths.integration.symlinks_dir.as_path(),
        ),
        ("icons directory", paths.integration.icons_dir.as_path()),
        ("appimages directory", paths.install.appimages_dir.as_path()),
        ("binaries directory", paths.install.binaries_dir.as_path()),
        ("archives directory", paths.install.archives_dir.as_path()),
        ("tmp directory", paths.install.tmp_dir.as_path()),
    ]
}

fn looks_like_legacy_layout(paths: &UpstreamPaths) -> bool {
    let old_package_dirs = [
        paths.dirs.data_dir.join("appimages"),
        paths.dirs.data_dir.join("binaries"),
        paths.dirs.data_dir.join("archives"),
    ];
    let old_dirs_exist = old_package_dirs.iter().any(|path| path.exists());
    let new_layout_incomplete = [
        paths.dirs.packages_dir.as_path(),
        paths.dirs.cache_dir.as_path(),
        paths.install.appimages_dir.as_path(),
        paths.install.binaries_dir.as_path(),
        paths.install.archives_dir.as_path(),
    ]
    .iter()
    .any(|path| !path.exists());

    old_dirs_exist && new_layout_incomplete
}

fn normalized_link_package_name(path: &Path) -> Option<String> {
    let file_name = path.file_name()?.to_string_lossy().to_string();
    #[cfg(windows)]
    {
        let name = file_name
            .strip_suffix(".exe")
            .or_else(|| file_name.strip_suffix(".EXE"))
            .unwrap_or(&file_name);
        return Some(name.to_string());
    }
    #[cfg(not(windows))]
    {
        Some(file_name)
    }
}

fn find_stale_symlink_names(symlinks_dir: &Path, installed_names: &HashSet<String>) -> Vec<String> {
    let Ok(entries) = fs::read_dir(symlinks_dir) else {
        return Vec::new();
    };

    let mut stale = Vec::new();
    for entry in entries.flatten() {
        let path = entry.path();
        let Ok(metadata) = fs::symlink_metadata(&path) else {
            continue;
        };
        let file_type = metadata.file_type();
        if !file_type.is_symlink() && !metadata.is_file() {
            continue;
        }

        let Some(name) = normalized_link_package_name(&path) else {
            continue;
        };
        if !installed_names.contains(&name) {
            stale.push(name);
        }
    }

    stale.sort();
    stale.dedup();
    stale
}

fn find_orphan_install_entries(
    install_roots: &[&Path],
    tracked_install_paths: &HashSet<PathBuf>,
) -> Vec<PathBuf> {
    let mut orphans = Vec::new();

    for root in install_roots {
        let Ok(entries) = fs::read_dir(root) else {
            continue;
        };

        for entry in entries.flatten() {
            let path = entry.path();
            if !tracked_install_paths.contains(&path) {
                orphans.push(path);
            }
        }
    }

    orphans.sort();
    orphans.dedup();
    orphans
}

pub(in crate::routines::doctor) fn check_local_layout(
    paths: &UpstreamPaths,
    report: &mut DoctorReport,
) {
    let legacy_layout_detected = looks_like_legacy_layout(paths);
    if legacy_layout_detected {
        report.line(Level::Warn, "legacy upstream data layout detected");
        report.hint(MIGRATE_DIR_HINT);
    }

    for (label, path) in required_directory_checks(paths) {
        if path.exists() {
            report.line(Level::Ok, format!("{} exists", label));
        } else {
            report.line(
                Level::Fail,
                format!("{} missing: {}", label, path.display()),
            );
            report.hint(if legacy_layout_detected {
                MIGRATE_DIR_HINT
            } else {
                HOOKS_INIT_DIR_HINT
            });
        }
    }
}

pub(in crate::routines::doctor) fn load_app_config(
    paths: &UpstreamPaths,
    report: &mut DoctorReport,
) -> Option<AppConfig> {
    if paths.config.config_file.exists() {
        match ConfigStorage::new(&paths.config.config_file) {
            Ok(storage) => {
                report.line(Level::Ok, "Config file exists");
                Some(storage.get_config().clone())
            }
            Err(err) => {
                report.line(Level::Fail, format!("Config file is invalid: {err}"));
                report.hint(MIGRATE_DIR_HINT);
                None
            }
        }
    } else {
        report.line(
            Level::Warn,
            format!(
                "Config file missing: {}",
                paths.config.config_file.display()
            ),
        );
        report.hint("Run `upstream hooks init` to generate the default config file.");
        None
    }
}

pub(in crate::routines::doctor) fn check_package_metadata_file(
    paths: &UpstreamPaths,
    report: &mut DoctorReport,
) {
    if paths.config.packages_file.exists() {
        report.line(Level::Ok, "Package metadata file exists");
    } else {
        report.line(
            Level::Warn,
            format!(
                "Package metadata file missing: {}",
                paths.config.packages_file.display()
            ),
        );
        report.hint("Run `upstream hooks init` to create package metadata storage.");
    }
}

pub(in crate::routines::doctor) fn check_untracked_package_artifacts(
    paths: &UpstreamPaths,
    all_packages: &[Package],
    report: &mut DoctorReport,
) {
    let installed_names: HashSet<String> = all_packages.iter().map(|p| p.name.clone()).collect();

    let stale_links = find_stale_symlink_names(&paths.integration.symlinks_dir, &installed_names);
    if stale_links.is_empty() {
        report.line(Level::Ok, "No stale symlinks detected");
    } else {
        report.line(
            Level::Warn,
            format!(
                "Detected {} stale symlink(s): {}",
                stale_links.len(),
                stale_links.join(", ")
            ),
        );
        report.hint(format!(
            "Remove stale symlinks from '{}' or run package removals with --purge.",
            paths.integration.symlinks_dir.display()
        ));
    }

    let tracked_install_paths: HashSet<PathBuf> = all_packages
        .iter()
        .filter_map(|package| package.install_path.clone())
        .collect();
    let orphan_install_entries = find_orphan_install_entries(
        &[
            paths.install.appimages_dir.as_path(),
            paths.install.binaries_dir.as_path(),
            paths.install.archives_dir.as_path(),
        ],
        &tracked_install_paths,
    );
    if orphan_install_entries.is_empty() {
        report.line(Level::Ok, "No untracked install artifacts detected");
    } else {
        let orphan_list = orphan_install_entries
            .iter()
            .map(|path| path.display().to_string())
            .collect::<Vec<_>>()
            .join(", ");
        report.line(
            Level::Warn,
            format!(
                "Detected {} untracked install artifact(s): {}",
                orphan_install_entries.len(),
                orphan_list
            ),
        );
        report.hint(
            "Delete untracked install artifacts manually, or recreate metadata and remove through upstream."
        );
    }
}

#[cfg(test)]
mod tests {
    use std::collections::HashSet;
    use std::fs;
    use std::io;
    use std::path::{Path, PathBuf};
    use std::time::{SystemTime, UNIX_EPOCH};

    use crate::utils::test_support;

    use super::{
        HOOKS_INIT_DIR_HINT, MIGRATE_DIR_HINT, find_orphan_install_entries,
        find_stale_symlink_names, looks_like_legacy_layout,
    };
    use crate::routines::doctor::checks::packages::expected_link_path;

    fn temp_root(name: &str) -> PathBuf {
        let nanos = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .map(|d| d.as_nanos())
            .unwrap_or(0);
        std::env::temp_dir().join(format!("upstream-doctor-test-{name}-{nanos}"))
    }

    fn cleanup(path: &Path) -> io::Result<()> {
        fs::remove_dir_all(path)
    }

    #[test]
    fn find_stale_symlink_names_reports_orphans() {
        let root = temp_root("stale");
        fs::create_dir_all(&root).expect("create root");

        let installed = expected_link_path(&root, "installed");
        let orphan = expected_link_path(&root, "orphan");
        fs::write(&installed, b"x").expect("create installed link file");
        fs::write(&orphan, b"x").expect("create orphan link file");

        let installed_names = HashSet::from(["installed".to_string()]);
        let stale = find_stale_symlink_names(&root, &installed_names);
        assert_eq!(stale, vec!["orphan".to_string()]);

        cleanup(&root).expect("cleanup");
    }

    #[test]
    fn find_orphan_install_entries_reports_untracked_paths() {
        let root = temp_root("orphan-install");
        let appimages = root.join("appimages");
        let binaries = root.join("binaries");
        let archives = root.join("archives");
        fs::create_dir_all(&appimages).expect("create appimages root");
        fs::create_dir_all(&binaries).expect("create binaries root");
        fs::create_dir_all(&archives).expect("create archives root");

        let tracked = binaries.join("tracked-bin");
        let orphan_file = appimages.join("orphan.AppImage");
        let orphan_dir = archives.join("orphan-dir");
        fs::write(&tracked, b"x").expect("create tracked file");
        fs::write(&orphan_file, b"x").expect("create orphan file");
        fs::create_dir_all(&orphan_dir).expect("create orphan dir");

        let tracked_paths = HashSet::from([tracked]);
        let orphans = find_orphan_install_entries(
            &[appimages.as_path(), binaries.as_path(), archives.as_path()],
            &tracked_paths,
        );

        assert_eq!(orphans.len(), 2);
        assert!(orphans.contains(&orphan_dir));
        assert!(orphans.contains(&orphan_file));

        cleanup(&root).expect("cleanup");
    }

    #[test]
    fn legacy_layout_detector_matches_old_package_dirs_with_missing_new_layout() {
        let root = temp_root("legacy-layout");
        let paths = test_support::upstream_paths(&root);
        fs::create_dir_all(paths.dirs.data_dir.join("binaries")).expect("create legacy binaries");

        assert!(looks_like_legacy_layout(&paths));

        fs::create_dir_all(&paths.dirs.packages_dir).expect("create packages");
        fs::create_dir_all(&paths.dirs.cache_dir).expect("create cache");
        fs::create_dir_all(&paths.install.appimages_dir).expect("create appimages");
        fs::create_dir_all(&paths.install.binaries_dir).expect("create binaries");
        fs::create_dir_all(&paths.install.archives_dir).expect("create archives");

        assert!(!looks_like_legacy_layout(&paths));

        cleanup(&root).expect("cleanup");
    }

    #[test]
    fn directory_hints_are_easy_to_distinguish() {
        assert!(MIGRATE_DIR_HINT.contains("upstream migrate"));
        assert!(HOOKS_INIT_DIR_HINT.contains("upstream hooks init"));
    }

    #[cfg(unix)]
    #[test]
    fn find_stale_symlink_names_includes_dangling_symlinks() {
        let root = temp_root("stale-dangling");
        fs::create_dir_all(&root).expect("create root");

        let dangling = expected_link_path(&root, "dangling");
        let missing_target = root.join("does-not-exist");
        std::os::unix::fs::symlink(&missing_target, &dangling).expect("create dangling symlink");

        let stale = find_stale_symlink_names(&root, &HashSet::new());
        assert_eq!(stale, vec!["dangling".to_string()]);

        cleanup(&root).expect("cleanup");
    }
}