use anyhow::{Context, Result};
use std::path::{Path, PathBuf};
use tokio::fs;
use super::config::{ClaudeAgentsIntegration, WorkspaceConfig};
#[derive(Debug, Clone, PartialEq)]
pub enum SymlinkStatus {
Valid,
InvalidTarget(PathBuf),
Missing,
InvalidType,
ParentMissing,
}
pub async fn create_symlink(config: &ClaudeAgentsIntegration, workspace_root: &Path) -> Result<()> {
if !config.enabled {
return Ok(());
}
let source_path = resolve_source_path(&config.source_path, workspace_root);
let target_path = expand_home_path(&config.target_path)?;
if !source_path.exists() {
anyhow::bail!("Source path does not exist: {}", source_path.display());
}
if let Some(parent) = target_path.parent() {
fs::create_dir_all(parent)
.await
.with_context(|| format!("Failed to create parent directory: {}", parent.display()))?;
}
if target_path.exists() {
if target_path.is_symlink() {
fs::remove_file(&target_path).await.with_context(|| {
format!(
"Failed to remove existing symlink: {}",
target_path.display()
)
})?;
} else if target_path.is_dir() {
fs::remove_dir_all(&target_path).await.with_context(|| {
format!(
"Failed to remove existing directory: {}",
target_path.display()
)
})?;
} else {
fs::remove_file(&target_path).await.with_context(|| {
format!("Failed to remove existing file: {}", target_path.display())
})?;
}
}
#[cfg(unix)]
{
fs::symlink(&source_path, &target_path)
.await
.with_context(|| {
format!(
"Failed to create symlink from {} to {}",
source_path.display(),
target_path.display()
)
})?;
}
#[cfg(windows)]
{
if source_path.is_dir() {
fs::symlink_dir(&source_path, &target_path)
.await
.with_context(|| {
format!(
"Failed to create directory symlink from {} to {}",
source_path.display(),
target_path.display()
)
})?;
} else {
fs::symlink_file(&source_path, &target_path)
.await
.with_context(|| {
format!(
"Failed to create file symlink from {} to {}",
source_path.display(),
target_path.display()
)
})?;
}
}
Ok(())
}
pub async fn remove_symlink(config: &ClaudeAgentsIntegration) -> Result<()> {
let target_path = expand_home_path(&config.target_path)?;
if target_path.exists() && target_path.is_symlink() {
fs::remove_file(&target_path)
.await
.with_context(|| format!("Failed to remove symlink: {}", target_path.display()))?;
}
Ok(())
}
pub async fn check_symlink_status(
config: &ClaudeAgentsIntegration,
workspace_root: &Path,
) -> Result<SymlinkStatus> {
let source_path = resolve_source_path(&config.source_path, workspace_root);
let target_path = expand_home_path(&config.target_path)?;
if !target_path.exists() {
if let Some(parent) = target_path.parent() {
if !parent.exists() {
return Ok(SymlinkStatus::ParentMissing);
}
}
return Ok(SymlinkStatus::Missing);
}
if !target_path.is_symlink() {
return Ok(SymlinkStatus::InvalidType);
}
let link_target = fs::read_link(&target_path)
.await
.with_context(|| format!("Failed to read symlink: {}", target_path.display()))?;
let resolved_target = if link_target.is_absolute() {
link_target
} else {
target_path
.parent()
.unwrap_or_else(|| Path::new("."))
.join(link_target)
};
let canonical_source = fs::canonicalize(&source_path).await.with_context(|| {
format!(
"Failed to canonicalize source path: {}",
source_path.display()
)
})?;
let canonical_target = fs::canonicalize(&resolved_target).await.with_context(|| {
format!(
"Failed to canonicalize target path: {}",
resolved_target.display()
)
})?;
if canonical_source == canonical_target {
Ok(SymlinkStatus::Valid)
} else {
Ok(SymlinkStatus::InvalidTarget(resolved_target))
}
}
pub async fn validate_paths(config: &ClaudeAgentsIntegration, workspace_root: &Path) -> Result<()> {
if !config.enabled {
return Ok(());
}
let source_path = resolve_source_path(&config.source_path, workspace_root);
if !source_path.exists() {
anyhow::bail!(
"Claude agents source path does not exist: {}",
source_path.display()
);
}
if !source_path.is_dir() {
anyhow::bail!(
"Claude agents source path is not a directory: {}",
source_path.display()
);
}
let mut has_agents = false;
let mut entries = fs::read_dir(&source_path)
.await
.with_context(|| format!("Failed to read source directory: {}", source_path.display()))?;
while let Some(entry) = entries.next_entry().await? {
let path = entry.path();
if path.is_file() && path.extension().and_then(|s| s.to_str()) == Some("md") {
has_agents = true;
break;
}
}
if !has_agents {
eprintln!(
"Warning: Claude agents source directory appears to be empty: {}",
source_path.display()
);
}
Ok(())
}
pub async fn configure_claude_agents(config: &WorkspaceConfig) -> Result<()> {
if let Some(claude_agents) = &config.claude_agents {
if claude_agents.enabled {
create_symlink(claude_agents, &config.workspace.root).await?;
}
}
Ok(())
}
pub async fn get_status_info(
config: &ClaudeAgentsIntegration,
workspace_root: &Path,
) -> Result<String> {
let source_path = resolve_source_path(&config.source_path, workspace_root);
let target_path = expand_home_path(&config.target_path)?;
let mut info = Vec::new();
info.push(format!("Enabled: {}", config.enabled));
info.push(format!("Source: {}", source_path.display()));
info.push(format!("Target: {}", target_path.display()));
if config.enabled {
let status = check_symlink_status(config, workspace_root).await?;
let status_str = match status {
SymlinkStatus::Valid => "✅ Valid symlink".to_string(),
SymlinkStatus::Missing => "❌ Symlink missing".to_string(),
SymlinkStatus::InvalidTarget(actual) => {
format!("⚠️ Symlink points to wrong target: {}", actual.display())
}
SymlinkStatus::InvalidType => "⚠️ Target exists but is not a symlink".to_string(),
SymlinkStatus::ParentMissing => "⚠️ Target parent directory missing".to_string(),
};
info.push(format!("Status: {status_str}"));
if source_path.exists() {
match count_agent_files(&source_path).await {
Ok(count) => info.push(format!("Agent files: {count}")),
Err(e) => info.push(format!("Error counting agents: {e}")),
}
}
}
Ok(info.join("\n"))
}
fn resolve_source_path(source_path: &Path, workspace_root: &Path) -> PathBuf {
if source_path.is_absolute() {
source_path.to_path_buf()
} else {
workspace_root.join(source_path)
}
}
fn expand_home_path(path: &Path) -> Result<PathBuf> {
let path_str = path
.to_str()
.ok_or_else(|| anyhow::anyhow!("Invalid path: {:?}", path))?;
if path_str.starts_with("~/") {
let home = dirs::home_dir()
.ok_or_else(|| anyhow::anyhow!("Could not determine home directory"))?;
Ok(home.join(&path_str[2..]))
} else if path_str == "~" {
dirs::home_dir().ok_or_else(|| anyhow::anyhow!("Could not determine home directory"))
} else {
Ok(path.to_path_buf())
}
}
async fn count_agent_files(source_path: &Path) -> Result<usize> {
let mut count = 0;
let mut entries = fs::read_dir(source_path)
.await
.with_context(|| format!("Failed to read directory: {}", source_path.display()))?;
while let Some(entry) = entries.next_entry().await? {
let path = entry.path();
if path.is_file() && path.extension().and_then(|s| s.to_str()) == Some("md") {
count += 1;
}
}
Ok(count)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[tokio::test]
async fn test_expand_home_path() {
let home = dirs::home_dir().unwrap();
let expanded = expand_home_path(Path::new("~/.claude/agents")).unwrap();
assert_eq!(expanded, home.join(".claude/agents"));
let expanded = expand_home_path(Path::new("~")).unwrap();
assert_eq!(expanded, home);
let expanded = expand_home_path(Path::new("/absolute/path")).unwrap();
assert_eq!(expanded, PathBuf::from("/absolute/path"));
}
#[tokio::test]
async fn test_resolve_source_path() {
let workspace_root = Path::new("/workspace");
let resolved = resolve_source_path(Path::new("agents"), workspace_root);
assert_eq!(resolved, PathBuf::from("/workspace/agents"));
let resolved = resolve_source_path(Path::new("/absolute/agents"), workspace_root);
assert_eq!(resolved, PathBuf::from("/absolute/agents"));
}
#[tokio::test]
async fn test_symlink_operations() -> Result<()> {
let temp_dir = TempDir::new()?;
let workspace_root = temp_dir.path();
let source_dir = workspace_root.join("wshobson/agents");
fs::create_dir_all(&source_dir).await?;
fs::write(source_dir.join("test-agent.md"), "# Test Agent").await?;
let target_dir = workspace_root.join(".claude");
fs::create_dir_all(&target_dir).await?;
let target_path = target_dir.join("agents");
let config = ClaudeAgentsIntegration {
enabled: true,
source_path: PathBuf::from("wshobson/agents"),
target_path: target_path.clone(),
};
create_symlink(&config, workspace_root).await?;
let status = check_symlink_status(&config, workspace_root).await?;
assert_eq!(status, SymlinkStatus::Valid);
remove_symlink(&config).await?;
let status = check_symlink_status(&config, workspace_root).await?;
assert_eq!(status, SymlinkStatus::Missing);
Ok(())
}
}