sklink 0.2.4

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

use crate::config;
use crate::error::AppError;
use crate::path_utils;
use crate::skills;

#[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)
    }
}

fn display_path(path: &Path) -> String {
    path.to_string_lossy().to_string()
}

pub fn sync_store_to_platforms(
    cwd: &Path,
    store_dir: &Path,
    config: &config::Config,
    platform: Option<&str>,
) -> Result<(), AppError> {
    let selected_skills = skills::discover_skills(store_dir)?;

    let platform = platform.unwrap_or("all");
    let platform_names: Vec<String> = if platform == "all" {
        let mut names: Vec<String> = config.platforms.keys().cloned().collect();
        names.sort();
        names
    } else {
        if !config.platforms.contains_key(platform) {
            let mut names: Vec<String> = config.platforms.keys().cloned().collect();
            names.sort();
            return Err(AppError::PlatformNotFound {
                platform: platform.to_string(),
                available: names.join(", "),
            });
        }
        vec![platform.to_string()]
    };

    for platform_name in platform_names {
        let Some(platform) = config.platforms.get(&platform_name) else {
            eprintln!("warning: platform not found: {platform_name}");
            continue;
        };

        for target in &platform.targets {
            let target_dir = path_utils::resolve_path(&target.dir, cwd)?;
            let meta = match std::fs::metadata(&target_dir) {
                Ok(m) => m,
                Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
                    eprintln!(
                        "warning: target dir not found (skipped): platform={platform_name} dir={}",
                        display_path(&target_dir)
                    );
                    continue;
                }
                Err(err) => {
                    eprintln!(
                        "warning: failed to read target dir (skipped): platform={platform_name} dir={} err={err}",
                        display_path(&target_dir)
                    );
                    continue;
                }
            };
            if !meta.is_dir() {
                eprintln!(
                    "warning: target path is not a directory (skipped): platform={platform_name} dir={}",
                    display_path(&target_dir)
                );
                continue;
            }

            for skill in &selected_skills {
                let link_path = target_dir.join(&skill.name);
                match ensure_correct_symlink(&link_path, &skill.dir)? {
                    InstallOutcome::Created => {
                        println!(
                            "created {} -> {}",
                            display_path(&link_path),
                            display_path(&skill.dir)
                        );
                    }
                    InstallOutcome::Skipped => {
                        println!("skipped {}", display_path(&link_path));
                    }
                }
            }
        }
    }

    Ok(())
}