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::install;
use crate::path_utils;
use crate::store;

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SkillDir {
    pub name: String,
    pub dir: PathBuf,
}

pub fn detect_repo_skills_dir(cwd: &Path) -> Result<PathBuf, AppError> {
    let in_repo_root = cwd.join("skills");
    let candidate = if in_repo_root.is_dir() {
        in_repo_root
    } else {
        let is_skills_dir = cwd
            .file_name()
            .and_then(|s| s.to_str())
            .map(|s| s == "skills")
            .unwrap_or(false);
        if is_skills_dir {
            cwd.to_path_buf()
        } else {
            return Err(AppError::RepoSkillsDirInvalid {
                path: in_repo_root,
                source: std::io::Error::new(std::io::ErrorKind::NotFound, "skills dir not found"),
            });
        }
    };

    std::fs::canonicalize(&candidate).map_err(|e| AppError::RepoSkillsDirInvalid {
        path: candidate,
        source: e,
    })
}

pub fn discover_skills(repo_skills_dir: &Path) -> Result<Vec<SkillDir>, AppError> {
    let entries = std::fs::read_dir(repo_skills_dir).map_err(|e| AppError::RepoSkillsDirRead {
        dir: repo_skills_dir.to_path_buf(),
        source: e,
    })?;

    let mut skills = Vec::new();
    for entry in entries {
        let entry = entry.map_err(|e| AppError::RepoSkillsDirRead {
            dir: repo_skills_dir.to_path_buf(),
            source: e,
        })?;
        let file_type = entry.file_type().map_err(|e| AppError::RepoSkillsDirRead {
            dir: repo_skills_dir.to_path_buf(),
            source: e,
        })?;
        if !file_type.is_dir() {
            continue;
        }

        let name = entry.file_name().to_string_lossy().to_string();
        let raw_dir = entry.path();
        let dir = std::fs::canonicalize(&raw_dir).map_err(|e| AppError::SkillNotFound {
            skill: name.clone(),
            path: raw_dir.clone(),
            source: e,
        })?;
        skills.push(SkillDir { name, dir });
    }

    skills.sort_by(|a, b| a.name.cmp(&b.name));
    skills.dedup_by(|a, b| a.name == b.name);
    Ok(skills)
}

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

pub fn list_available(cwd: &Path) -> Result<(), AppError> {
    let store_dir = store::default_store_dir(cwd)?;

    let skills_dir = detect_repo_skills_dir(cwd).unwrap_or(store_dir);
    let skills = discover_skills(&skills_dir)?;
    for skill in skills {
        println!("{}", skill.name);
    }
    Ok(())
}

pub fn validate_skill_dir(skill: &str, dir: &PathBuf) -> Result<(), AppError> {
    let meta = std::fs::metadata(dir).map_err(|e| AppError::SkillNotFound {
        skill: skill.to_string(),
        path: dir.clone(),
        source: e,
    })?;
    if !meta.is_dir() {
        return Err(AppError::SkillNotFound {
            skill: skill.to_string(),
            path: dir.clone(),
            source: std::io::Error::other("not a directory"),
        });
    }
    Ok(())
}

pub fn list_installed(cwd: &Path) -> Result<(), AppError> {
    let config = config::load_default_config()?;
    let store_dir = store::default_store_dir(cwd)?;
    let store_dir = std::fs::canonicalize(&store_dir).ok();

    let mut platform_names: Vec<String> = config.platforms.keys().cloned().collect();
    platform_names.sort();

    for platform_name in platform_names {
        let Some(platform) = config.platforms.get(&platform_name) else {
            continue;
        };

        println!("{platform_name}");

        let mut target_dirs = Vec::new();
        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;
            }
            target_dirs.push(target_dir);
        }

        target_dirs.sort_by_key(|a| display_path(a));

        for (target_idx, target_dir) in target_dirs.iter().enumerate() {
            let target_prefix = if target_idx + 1 == target_dirs.len() {
                "└──"
            } else {
                "├──"
            };
            println!("{target_prefix} {}", display_path(target_dir));

            let entries = match std::fs::read_dir(target_dir) {
                Ok(e) => e,
                Err(err) => {
                    eprintln!(
                        "warning: failed to list target dir (skipped): platform={platform_name} dir={} err={err}",
                        display_path(target_dir)
                    );
                    continue;
                }
            };

            let mut rendered = Vec::new();
            for entry in entries {
                let entry = match entry {
                    Ok(e) => e,
                    Err(err) => {
                        eprintln!(
                            "warning: failed to read target entry (skipped): platform={platform_name} dir={} err={err}",
                            display_path(target_dir)
                        );
                        continue;
                    }
                };
                let name = entry.file_name().to_string_lossy().to_string();
                if name == ".DS_Store" {
                    continue;
                }

                let path = entry.path();
                let meta = match std::fs::symlink_metadata(&path) {
                    Ok(m) => m,
                    Err(err) => {
                        let line = format!("[?] {name} (error:{err})");
                        rendered.push((name, line));
                        continue;
                    }
                };

                let line = if meta.file_type().is_symlink() {
                    match std::fs::read_link(&path) {
                        Ok(raw_target) => {
                            let resolved = install::resolve_symlink_target(&path, &raw_target);
                            match std::fs::canonicalize(&resolved) {
                                Ok(resolved) => {
                                    let status = match &store_dir {
                                        Some(store_dir) => {
                                            let expected = store_dir.join(&name);
                                            let expected = std::fs::canonicalize(expected).ok();
                                            if expected.is_some_and(|e| e == resolved) {
                                                "ok".to_string()
                                            } else {
                                                "outside-store".to_string()
                                            }
                                        }
                                        None => "unknown-store".to_string(),
                                    };
                                    format!("[L] {name} -> {} ({status})", display_path(&resolved))
                                }
                                Err(err) => format!("[L] {name} (broken:{err})"),
                            }
                        }
                        Err(err) => format!("[L] {name} (broken:{err})"),
                    }
                } else if meta.is_dir() {
                    format!("[D] {name}")
                } else if meta.is_file() {
                    format!("[F] {name}")
                } else {
                    format!("[?] {name}")
                };

                rendered.push((name, line));
            }

            rendered.sort_by(|a, b| a.0.cmp(&b.0));

            for (entry_idx, (_, line)) in rendered.iter().enumerate() {
                let has_more_targets = target_idx + 1 != target_dirs.len();
                let indent = if has_more_targets { "" } else { "    " };
                let prefix = if entry_idx + 1 == rendered.len() {
                    "└──"
                } else {
                    "├──"
                };
                println!("{indent}{prefix} {line}");
            }
        }
    }

    Ok(())
}