use crate::Result;
use crate::config::{TargetSpec, ensure_dir};
use crate::error::SkillcError;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DeployMethod {
Symlink,
Junction,
Copy,
}
impl std::fmt::Display for DeployMethod {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
DeployMethod::Symlink => write!(f, "symlink"),
DeployMethod::Junction => write!(f, "junction"),
DeployMethod::Copy => write!(f, "copy"),
}
}
}
#[derive(Debug)]
pub struct DeployResult {
pub target: PathBuf,
pub method: DeployMethod,
}
pub fn deploy_to_agent(
ssot_path: &Path,
target: &TargetSpec,
skill_name: &str,
force_copy: bool,
project_root: Option<&Path>,
) -> Result<DeployResult> {
if !ssot_path.exists() {
return Err(SkillcError::DirectoryNotFound(format!(
"SSOT path does not exist: {}",
ssot_path.display()
)));
}
let agent_dir = if target.is_known() {
target.skills_path(project_root)?
} else {
target.skills_path(None)?
};
let dest = agent_dir.join(skill_name);
ensure_dir(&agent_dir)?;
if dest.exists() {
if is_link(&dest) {
remove_link(&dest)?;
} else if force_copy {
std::fs::remove_dir_all(&dest).map_err(|e| {
SkillcError::Internal(format!(
"Failed to remove existing directory {}: {}",
dest.display(),
e
))
})?;
} else {
return Err(SkillcError::Internal(format!(
"Destination exists and is not a symlink: {}. Use --copy to overwrite.",
dest.display()
)));
}
}
let method = if force_copy {
crate::util::copy_dir_recursive(ssot_path, &dest)?;
DeployMethod::Copy
} else {
create_link(ssot_path, &dest)?
};
Ok(DeployResult {
target: dest,
method,
})
}
fn is_link(path: &Path) -> bool {
path.symlink_metadata()
.map(|m| m.file_type().is_symlink())
.unwrap_or(false)
}
fn remove_link(path: &Path) -> Result<()> {
#[cfg(unix)]
{
std::fs::remove_file(path)?;
}
#[cfg(windows)]
{
std::fs::remove_dir(path)?;
}
Ok(())
}
#[cfg(unix)]
fn create_link(source: &Path, target: &Path) -> Result<DeployMethod> {
let abs_source = source.canonicalize().map_err(|e| {
SkillcError::Internal(format!(
"Failed to canonicalize source path {}: {}",
source.display(),
e
))
})?;
std::os::unix::fs::symlink(&abs_source, target).map_err(|e| {
SkillcError::Internal(format!(
"Failed to create symlink {} -> {}: {}",
target.display(),
abs_source.display(),
e
))
})?;
Ok(DeployMethod::Symlink)
}
#[cfg(windows)]
fn create_link(source: &Path, target: &Path) -> Result<DeployMethod> {
let abs_source = source.canonicalize().map_err(|e| {
SkillcError::Internal(format!(
"Failed to canonicalize source path {}: {}",
source.display(),
e
))
})?;
match std::os::windows::fs::symlink_dir(&abs_source, target) {
Ok(()) => return Ok(DeployMethod::Symlink),
Err(_) => {
}
}
match junction::create(&abs_source, target) {
Ok(()) => return Ok(DeployMethod::Junction),
Err(_) => {
}
}
eprintln!("warning: Could not create symlink or junction. Using copy instead.");
crate::util::copy_dir_recursive(source, target)?;
Ok(DeployMethod::Copy)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_deploy_creates_symlink() {
let temp = TempDir::new().expect("create temp dir");
let ssot = temp.path().join("ssot").join("test-skill");
let agent_dir = temp.path().join("agent");
std::fs::create_dir_all(&ssot).expect("create test dir");
std::fs::write(ssot.join("SKILL.md"), "# Test").expect("test operation");
let result =
deploy_to_agent_internal(&ssot, &agent_dir, "test-skill", false).expect("deploy");
assert!(result.target.exists());
#[cfg(unix)]
assert_eq!(result.method, DeployMethod::Symlink);
#[cfg(windows)]
assert!(matches!(
result.method,
DeployMethod::Symlink | DeployMethod::Copy
));
let content =
std::fs::read_to_string(result.target.join("SKILL.md")).expect("test operation");
assert_eq!(content, "# Test");
}
#[test]
fn test_deploy_removes_existing_symlink() {
let temp = TempDir::new().expect("create temp dir");
let ssot1 = temp.path().join("ssot1").join("test-skill");
let ssot2 = temp.path().join("ssot2").join("test-skill");
let agent_dir = temp.path().join("agent");
std::fs::create_dir_all(&ssot1).expect("create test dir");
std::fs::write(ssot1.join("SKILL.md"), "# Version 1").expect("test operation");
std::fs::create_dir_all(&ssot2).expect("create test dir");
std::fs::write(ssot2.join("SKILL.md"), "# Version 2").expect("test operation");
deploy_to_agent_internal(&ssot1, &agent_dir, "test-skill", false).expect("deploy");
let result =
deploy_to_agent_internal(&ssot2, &agent_dir, "test-skill", false).expect("deploy");
let content =
std::fs::read_to_string(result.target.join("SKILL.md")).expect("test operation");
assert_eq!(content, "# Version 2");
}
#[test]
fn test_deploy_force_copy() {
let temp = TempDir::new().expect("create temp dir");
let ssot = temp.path().join("ssot").join("test-skill");
let agent_dir = temp.path().join("agent");
std::fs::create_dir_all(&ssot).expect("create test dir");
std::fs::write(ssot.join("SKILL.md"), "# Test").expect("test operation");
let result =
deploy_to_agent_internal(&ssot, &agent_dir, "test-skill", true).expect("deploy");
assert_eq!(result.method, DeployMethod::Copy);
assert!(!is_link(&result.target));
let content =
std::fs::read_to_string(result.target.join("SKILL.md")).expect("test operation");
assert_eq!(content, "# Test");
}
#[test]
fn test_deploy_fails_on_existing_directory() {
let temp = TempDir::new().expect("create temp dir");
let ssot = temp.path().join("ssot").join("test-skill");
let agent_dir = temp.path().join("agent");
let target = agent_dir.join("test-skill");
std::fs::create_dir_all(&ssot).expect("create test dir");
std::fs::write(ssot.join("SKILL.md"), "# Test").expect("test operation");
std::fs::create_dir_all(&target).expect("create test dir");
std::fs::write(target.join("existing.txt"), "existing").expect("test operation");
let result = deploy_to_agent_internal(&ssot, &agent_dir, "test-skill", false);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("not a symlink"));
}
fn deploy_to_agent_internal(
ssot_path: &Path,
agent_dir: &Path,
skill_name: &str,
force_copy: bool,
) -> Result<DeployResult> {
if !ssot_path.exists() {
return Err(SkillcError::DirectoryNotFound(format!(
"SSOT path does not exist: {}",
ssot_path.display()
)));
}
let target = agent_dir.join(skill_name);
ensure_dir(agent_dir)?;
if target.exists() && !is_link(&target) {
return Err(SkillcError::Internal(format!(
"Target exists and is not a symlink: {}. Use --force to overwrite.",
target.display()
)));
}
if is_link(&target) {
remove_link(&target)?;
}
let method = if force_copy {
crate::util::copy_dir_recursive(ssot_path, &target)?;
DeployMethod::Copy
} else {
create_link(ssot_path, &target)?
};
Ok(DeployResult { target, method })
}
}