use std::os::unix::fs::symlink;
use std::path::{Path, PathBuf};
use anyhow::Result;
use serde::Serialize;
use crate::{harness, paths};
#[derive(Debug, Clone, Serialize)]
pub struct LinkResult {
pub harness: String,
pub skill: String,
pub link_path: String,
pub status: LinkStatus,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum LinkStatus {
Linked,
AlreadyLinked,
SameRoot,
Conflict,
Failed,
}
impl LinkStatus {
pub fn as_str(&self) -> &'static str {
match self {
LinkStatus::Linked => "linked",
LinkStatus::AlreadyLinked => "already linked",
LinkStatus::SameRoot => "same root",
LinkStatus::Conflict => "conflict",
LinkStatus::Failed => "failed",
}
}
}
pub fn link_skill(skill_name: &str) -> Result<Vec<LinkResult>> {
let canonical = paths::skill_dir(skill_name)?;
let mut results = Vec::new();
for info in harness::detect() {
if !info.detected {
continue;
}
let Some(target_dir) = harness::skills_dir(&info.key) else {
continue; };
results.push(link_into(&info.name, skill_name, &canonical, &target_dir));
}
Ok(results)
}
pub fn link_all(all: bool) -> Result<Vec<LinkResult>> {
let root = paths::skills_root()?;
let mut results = Vec::new();
let Ok(entries) = std::fs::read_dir(&root) else {
return Ok(results);
};
let mut names: Vec<String> = entries
.flatten()
.filter_map(|entry| {
let path = entry.path();
let skill_md = path.join("SKILL.md");
if !skill_md.exists() {
return None;
}
if !all
&& crate::catalog::skill_origin(&skill_md.to_string_lossy())
!= crate::catalog::ORIGIN_GALDR
{
return None;
}
path.file_name()
.and_then(|n| n.to_str())
.map(str::to_string)
})
.collect();
names.sort();
for name in names {
results.extend(link_skill(&name)?);
}
Ok(results)
}
fn link_into(harness_name: &str, skill: &str, canonical: &Path, target_dir: &Path) -> LinkResult {
let link_path = target_dir.join(skill);
let mk = |status| LinkResult {
harness: harness_name.to_string(),
skill: skill.to_string(),
link_path: link_path.display().to_string(),
status,
};
if same_dir(target_dir, canonical.parent().unwrap_or(canonical)) {
return mk(LinkStatus::SameRoot);
}
match std::fs::symlink_metadata(&link_path) {
Ok(meta) if meta.file_type().is_symlink() => {
match std::fs::read_link(&link_path) {
Ok(dest) if same_dir(&dest, canonical) => mk(LinkStatus::AlreadyLinked),
_ => {
let _ = std::fs::remove_file(&link_path);
create(canonical, &link_path, mk)
}
}
}
Ok(_) => mk(LinkStatus::Conflict),
Err(_) => create(canonical, &link_path, mk),
}
}
fn create(canonical: &Path, link_path: &Path, mk: impl Fn(LinkStatus) -> LinkResult) -> LinkResult {
if let Some(parent) = link_path.parent() {
let _ = std::fs::create_dir_all(parent);
}
match symlink(canonical, link_path) {
Ok(()) => mk(LinkStatus::Linked),
Err(_) => mk(LinkStatus::Failed),
}
}
fn same_dir(a: &Path, b: &Path) -> bool {
match (a.canonicalize(), b.canonicalize()) {
(Ok(a), Ok(b)) => a == b,
_ => normalize(a) == normalize(b),
}
}
fn normalize(p: &Path) -> PathBuf {
PathBuf::from(p.to_string_lossy().trim_end_matches('/'))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn link_status_strings_are_stable() {
assert_eq!(LinkStatus::Linked.as_str(), "linked");
assert_eq!(LinkStatus::Conflict.as_str(), "conflict");
}
#[test]
fn same_dir_ignores_a_trailing_slash() {
assert!(same_dir(Path::new("/a/b"), Path::new("/a/b/")));
assert!(!same_dir(Path::new("/a/b"), Path::new("/a/c")));
}
}