sklink 0.2.0

Install skills into platform directories via a local store and symlinks
use std::path::{Path, PathBuf};

use crate::error::AppError;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InstallOutcome {
    Created,
    Skipped,
}

pub fn ensure_correct_symlink(
    link_path: &Path,
    link_target: &Path,
) -> Result<InstallOutcome, AppError> {
    match std::fs::symlink_metadata(link_path) {
        Ok(meta) => {
            if !meta.file_type().is_symlink() {
                return Err(AppError::LinkPathNotSymlink {
                    path: link_path.to_path_buf(),
                });
            }

            let raw_target = std::fs::read_link(link_path).map_err(|e| AppError::ReadLink {
                path: link_path.to_path_buf(),
                source: e,
            })?;

            let actual = resolve_symlink_target(link_path, &raw_target);
            let actual = std::fs::canonicalize(&actual).map_err(AppError::Io)?;
            let expected = std::fs::canonicalize(link_target).map_err(AppError::Io)?;

            if actual == expected {
                Ok(InstallOutcome::Skipped)
            } else {
                Err(AppError::LinkPathWrongTarget {
                    path: link_path.to_path_buf(),
                    actual,
                    expected,
                })
            }
        }
        Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
            std::os::unix::fs::symlink(link_target, link_path).map_err(|e| {
                AppError::CreateSymlink {
                    path: link_path.to_path_buf(),
                    target: link_target.to_path_buf(),
                    source: e,
                }
            })?;
            Ok(InstallOutcome::Created)
        }
        Err(err) => Err(AppError::Io(err)),
    }
}

pub fn resolve_symlink_target(link_path: &Path, raw_target: &PathBuf) -> PathBuf {
    if raw_target.is_absolute() {
        raw_target.clone()
    } else {
        let parent = link_path.parent().unwrap_or_else(|| Path::new("."));
        parent.join(raw_target)
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn canonical(path: &Path) -> PathBuf {
        std::fs::canonicalize(path).unwrap()
    }

    #[test]
    fn ensure_correct_symlink_creates_when_missing() {
        let dir = tempfile::tempdir().unwrap();
        let link_path = dir.path().join("link");
        let link_target = dir.path().join("target");
        std::fs::create_dir_all(&link_target).unwrap();

        let outcome = ensure_correct_symlink(&link_path, &link_target).unwrap();
        assert_eq!(outcome, InstallOutcome::Created);

        let meta = std::fs::symlink_metadata(&link_path).unwrap();
        assert!(meta.file_type().is_symlink());

        let raw = std::fs::read_link(&link_path).unwrap();
        let resolved = resolve_symlink_target(&link_path, &raw);
        assert_eq!(canonical(&resolved), canonical(&link_target));
    }

    #[test]
    fn ensure_correct_symlink_skips_when_correct() {
        let dir = tempfile::tempdir().unwrap();
        let link_path = dir.path().join("link");
        let link_target = dir.path().join("target");
        std::fs::create_dir_all(&link_target).unwrap();

        ensure_correct_symlink(&link_path, &link_target).unwrap();
        let outcome = ensure_correct_symlink(&link_path, &link_target).unwrap();
        assert_eq!(outcome, InstallOutcome::Skipped);
    }

    #[test]
    fn ensure_correct_symlink_errors_when_existing_path_is_not_symlink() {
        let dir = tempfile::tempdir().unwrap();
        let link_path = dir.path().join("link");
        let link_target = dir.path().join("target");
        std::fs::create_dir_all(&link_target).unwrap();
        std::fs::write(&link_path, "not a symlink").unwrap();

        let err = ensure_correct_symlink(&link_path, &link_target).unwrap_err();
        match err {
            AppError::LinkPathNotSymlink { path } => assert_eq!(path, link_path),
            other => panic!("unexpected error: {other:?}"),
        }
    }

    #[test]
    fn ensure_correct_symlink_errors_when_symlink_points_elsewhere() {
        let dir = tempfile::tempdir().unwrap();
        let link_path = dir.path().join("link");
        let expected = dir.path().join("expected");
        let other = dir.path().join("other");
        std::fs::create_dir_all(&expected).unwrap();
        std::fs::create_dir_all(&other).unwrap();

        std::os::unix::fs::symlink(&other, &link_path).unwrap();
        let err = ensure_correct_symlink(&link_path, &expected).unwrap_err();
        match err {
            AppError::LinkPathWrongTarget {
                path,
                actual,
                expected,
            } => {
                assert_eq!(path, link_path);
                assert_eq!(actual, canonical(&other));
                assert_eq!(expected, canonical(&dir.path().join("expected")));
            }
            other => panic!("unexpected error: {other:?}"),
        }
    }

    #[test]
    fn ensure_correct_symlink_accepts_relative_symlink_target() {
        let dir = tempfile::tempdir().unwrap();
        let repo = dir.path().join("repo");
        let targets = dir.path().join("targets");
        std::fs::create_dir_all(&repo).unwrap();
        std::fs::create_dir_all(&targets).unwrap();

        let link_target = repo.join("skill");
        std::fs::create_dir_all(&link_target).unwrap();

        let link_path = targets.join("skill");
        std::os::unix::fs::symlink("../repo/skill", &link_path).unwrap();

        let outcome = ensure_correct_symlink(&link_path, &link_target).unwrap();
        assert_eq!(outcome, InstallOutcome::Skipped);
    }
}