use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use ito_config::ito_dir::lexical_normalize;
use ito_config::types::CoordinationStorage;
use crate::errors::{CoreError, CoreResult};
pub const COORDINATION_DIRS: &[&str] = &["changes", "specs", "modules", "workflows", "audit"];
const COORDINATION_GITIGNORE_ENTRIES: &[&str] = &[
".ito/changes",
".ito/specs",
".ito/modules",
".ito/workflows",
".ito/audit",
];
#[must_use]
pub fn gitignore_entries() -> &'static [&'static str] {
COORDINATION_GITIGNORE_ENTRIES
}
pub fn create_dir_link(src: &Path, dst: &Path) -> io::Result<()> {
#[cfg(unix)]
{
std::os::unix::fs::symlink(src, dst)
}
#[cfg(windows)]
{
junction::create(src, dst)
}
#[cfg(not(any(unix, windows)))]
{
let _ = (src, dst);
Err(io::Error::new(
io::ErrorKind::Unsupported,
"directory symlinks are not supported on this platform",
))
}
}
fn read_link_opt(path: &Path) -> io::Result<Option<PathBuf>> {
match read_dir_link(path) {
Ok(target) => Ok(Some(target)),
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None),
Err(e) if e.kind() == io::ErrorKind::InvalidInput => Ok(None),
Err(_) if path.exists() => Ok(None),
Err(e) => Err(e),
}
}
pub fn wire_coordination_symlinks(ito_path: &Path, worktree_ito_path: &Path) -> CoreResult<()> {
for dir in COORDINATION_DIRS {
let src = ito_path.join(dir);
let dst = worktree_ito_path.join(dir);
let existing_link = read_link_opt(&src).map_err(|e| {
CoreError::io(
format!(
"cannot read symlink status of '{}': check filesystem permissions",
src.display()
),
e,
)
})?;
if let Some(target) = existing_link {
let resolved_target = if target.is_absolute() {
lexical_normalize(&target)
} else {
lexical_normalize(&ito_path.join(&target))
};
let resolved_dst = lexical_normalize(&dst);
if resolved_target == resolved_dst || target == dst {
fs::create_dir_all(&dst).map_err(|e| {
CoreError::io(
format!(
"cannot create coordination directory '{}' for existing symlink '{}': ensure the worktree path is writable",
dst.display(),
src.display()
),
e,
)
})?;
continue;
}
return Err(CoreError::process(format!(
"Coordination symlink '{}' points to '{}' but should point to '{}'. Delete or move the wrong symlink manually, then run `ito init` again.",
src.display(),
resolved_target.display(),
resolved_dst.display()
)));
} else if src.exists() {
if !src.is_dir() {
return Err(CoreError::process(format!(
"Coordination path '{}' exists but is not a directory or symlink. Move it aside, then run `ito init` again to wire '{}'.",
src.display(),
dst.display()
)));
}
if !is_dir_empty(&src)? {
return Err(CoreError::process(format!(
"Coordination path '{}' is a non-empty directory, not a symlink to '{}'. Move or merge its contents manually, remove the directory, then run `ito init` again.",
src.display(),
dst.display()
)));
}
fs::remove_dir(&src).map_err(|e| {
CoreError::io(
format!(
"cannot remove empty coordination directory '{}': remove it manually and retry",
src.display()
),
e,
)
})?;
}
fs::create_dir_all(&dst).map_err(|e| {
CoreError::io(
format!(
"cannot create coordination directory '{}': ensure the worktree path is \
writable",
dst.display()
),
e,
)
})?;
create_dir_link(&dst, &src).map_err(|e| {
CoreError::io(
format!(
"cannot create symlink '{}' → '{}': on Linux/macOS ensure you have write \
permission to '{}'; on Windows you may need Developer Mode or elevated \
privileges",
src.display(),
dst.display(),
ito_path.display()
),
e,
)
})?;
}
Ok(())
}
fn is_dir_empty(path: &Path) -> CoreResult<bool> {
let mut entries = fs::read_dir(path).map_err(|e| {
CoreError::io(
format!(
"cannot read coordination directory '{}' to verify it is empty: check filesystem permissions",
path.display()
),
e,
)
})?;
Ok(entries.next().is_none())
}
fn copy_dir_recursive(src: &Path, dst: &Path) -> CoreResult<()> {
fs::create_dir_all(dst).map_err(|e| {
CoreError::io(
format!(
"cannot create directory '{}' during recursive copy: ensure the target \
path is writable",
dst.display()
),
e,
)
})?;
let entries = fs::read_dir(src).map_err(|e| {
CoreError::io(
format!(
"cannot read directory '{}' during recursive copy: check filesystem \
permissions",
src.display()
),
e,
)
})?;
for entry in entries {
let entry = entry.map_err(|e| {
CoreError::io(
format!(
"cannot read directory entry in '{}' during recursive copy",
src.display()
),
e,
)
})?;
let from = entry.path();
let to = dst.join(entry.file_name());
let file_type = entry.file_type().map_err(|e| {
CoreError::io(
format!(
"cannot determine file type of '{}' during recursive copy",
from.display()
),
e,
)
})?;
if file_type.is_symlink() {
let target = read_dir_link(&from).map_err(|e| {
CoreError::io(
format!(
"cannot read symlink '{}' during recursive copy",
from.display()
),
e,
)
})?;
#[cfg(unix)]
std::os::unix::fs::symlink(&target, &to).map_err(|e| {
CoreError::io(
format!(
"cannot recreate symlink '{}' -> '{}' during recursive copy: ensure \
'{}' is writable",
to.display(),
target.display(),
dst.display()
),
e,
)
})?;
#[cfg(windows)]
{
junction::create(&target, &to).map_err(|e| {
CoreError::io(
format!(
"cannot recreate directory junction '{}' -> '{}' during recursive \
copy: ensure '{}' is writable",
to.display(),
target.display(),
dst.display()
),
e,
)
})?;
}
} else if file_type.is_dir() {
copy_dir_recursive(&from, &to)?;
} else {
fs::copy(&from, &to).map_err(|e| {
CoreError::io(
format!(
"cannot copy '{}' to '{}' during recursive copy: ensure '{}' is \
writable",
from.display(),
to.display(),
dst.display()
),
e,
)
})?;
}
}
Ok(())
}
pub fn update_gitignore_for_symlinks(project_root: &Path) -> CoreResult<()> {
let gitignore_path = project_root.join(".gitignore");
let existing = if gitignore_path.exists() {
fs::read_to_string(&gitignore_path).map_err(|e| {
CoreError::io(
format!(
"cannot read '{}': check filesystem permissions",
gitignore_path.display()
),
e,
)
})?
} else {
String::new()
};
let missing: Vec<&str> = gitignore_entries()
.iter()
.copied()
.filter(|line| !existing.lines().any(|l| l.trim() == *line))
.collect();
if missing.is_empty() {
return Ok(());
}
let mut content = existing;
if !content.is_empty() && !content.ends_with('\n') {
content.push('\n');
}
content.push_str("\n# Ito coordination worktree symlinks\n");
for line in &missing {
content.push_str(line);
content.push('\n');
}
fs::write(&gitignore_path, &content).map_err(|e| {
CoreError::io(
format!(
"cannot write '{}': check filesystem permissions",
gitignore_path.display()
),
e,
)
})?;
Ok(())
}
pub fn remove_coordination_symlinks(ito_path: &Path, worktree_ito_path: &Path) -> CoreResult<()> {
for dir in COORDINATION_DIRS {
let link_path = ito_path.join(dir);
let worktree_dir = worktree_ito_path.join(dir);
let existing_link = read_link_opt(&link_path).map_err(|e| {
CoreError::io(
format!(
"cannot read symlink status of '{}': check filesystem permissions",
link_path.display()
),
e,
)
})?;
let Some(_target) = existing_link else {
continue;
};
fs::remove_file(&link_path).map_err(|e| {
CoreError::io(
format!(
"cannot remove symlink '{}': delete it manually and retry",
link_path.display()
),
e,
)
})?;
fs::create_dir_all(&link_path).map_err(|e| {
CoreError::io(
format!(
"cannot create directory '{}' after removing symlink: check filesystem \
permissions",
link_path.display()
),
e,
)
})?;
if worktree_dir.exists() {
let entries = fs::read_dir(&worktree_dir).map_err(|e| {
CoreError::io(
format!(
"cannot read worktree directory '{}' during symlink teardown",
worktree_dir.display()
),
e,
)
})?;
for entry in entries {
let entry = entry.map_err(|e| {
CoreError::io(
format!(
"cannot read directory entry in '{}' during teardown",
worktree_dir.display()
),
e,
)
})?;
let from = entry.path();
let to = link_path.join(entry.file_name());
move_entry_with_fallback(&from, &to, &link_path)?;
}
}
}
Ok(())
}
#[derive(Debug, PartialEq)]
pub enum CoordinationHealthStatus {
Healthy,
Embedded,
WorktreeMissing {
expected_path: PathBuf,
},
BrokenSymlinks {
broken: Vec<(PathBuf, PathBuf)>,
},
WrongTargets {
mismatched: Vec<(PathBuf, PathBuf, PathBuf)>,
},
NotWired {
dirs: Vec<PathBuf>,
},
}
pub fn check_coordination_health(
ito_path: &Path,
worktree_ito_path: &Path,
storage: &CoordinationStorage,
) -> CoordinationHealthStatus {
if *storage == CoordinationStorage::Embedded {
return CoordinationHealthStatus::Embedded;
}
if !worktree_ito_path.exists() {
return CoordinationHealthStatus::WorktreeMissing {
expected_path: worktree_ito_path.to_path_buf(),
};
}
let mut broken: Vec<(PathBuf, PathBuf)> = Vec::new();
let mut mismatched: Vec<(PathBuf, PathBuf, PathBuf)> = Vec::new();
let mut not_wired: Vec<PathBuf> = Vec::new();
for dir in COORDINATION_DIRS {
let link_path = ito_path.join(dir);
let expected_target = lexical_normalize(&worktree_ito_path.join(dir));
if !link_path.exists() && fs::read_link(&link_path).is_err() {
not_wired.push(link_path);
continue;
}
match read_dir_link(&link_path) {
Ok(target) => {
let resolved = if target.is_absolute() {
lexical_normalize(&target)
} else {
lexical_normalize(&ito_path.join(&target))
};
if !resolved.exists() {
broken.push((link_path, target));
} else if resolved != expected_target {
mismatched.push((link_path, resolved, expected_target));
}
}
Err(e) if e.kind() == io::ErrorKind::InvalidInput => {
if link_path.exists() {
not_wired.push(link_path);
}
}
Err(_) => {
if link_path.exists() {
not_wired.push(link_path);
}
}
}
}
if !broken.is_empty() {
return CoordinationHealthStatus::BrokenSymlinks { broken };
}
if !mismatched.is_empty() {
return CoordinationHealthStatus::WrongTargets { mismatched };
}
if !not_wired.is_empty() {
return CoordinationHealthStatus::NotWired { dirs: not_wired };
}
CoordinationHealthStatus::Healthy
}
fn move_entry_with_fallback(from: &Path, to: &Path, target_root: &Path) -> CoreResult<()> {
let rename_err = match fs::rename(from, to) {
Ok(()) => return Ok(()),
Err(e) => e,
};
if rename_err.kind() != io::ErrorKind::CrossesDevices {
return Err(CoreError::io(
format!(
"cannot move '{}' to '{}' during coordination teardown: ensure both paths are accessible and retry",
from.display(),
to.display()
),
rename_err,
));
}
let file_type = fs::symlink_metadata(from).map_err(|e| {
CoreError::io(
format!(
"cannot inspect '{}' during cross-filesystem coordination teardown",
from.display()
),
e,
)
})?;
if file_type.file_type().is_symlink() {
copy_symlink(from, to)?;
remove_copied_symlink(from)?;
} else if file_type.is_dir() {
copy_dir_recursive(from, to)?;
fs::remove_dir_all(from).map_err(|e| {
CoreError::io(
format!(
"cannot remove source directory '{}' after cross-filesystem teardown copy: remove it manually and retry",
from.display()
),
e,
)
})?;
} else {
fs::copy(from, to).map_err(|e| {
CoreError::io(
format!(
"cannot copy '{}' to '{}' during coordination teardown: ensure '{}' is writable",
from.display(),
to.display(),
target_root.display()
),
e,
)
})?;
fs::remove_file(from).map_err(|e| {
CoreError::io(
format!(
"cannot remove source file '{}' after cross-filesystem teardown copy: remove it manually and retry",
from.display()
),
e,
)
})?;
}
Ok(())
}
fn copy_symlink(from: &Path, to: &Path) -> CoreResult<()> {
let target = read_dir_link(from).map_err(|e| {
CoreError::io(
format!("cannot read symlink '{}' during copy", from.display()),
e,
)
})?;
#[cfg(unix)]
{
std::os::unix::fs::symlink(&target, to).map_err(|e| {
CoreError::io(
format!(
"cannot recreate symlink '{}' -> '{}' during copy",
to.display(),
target.display()
),
e,
)
})?;
}
#[cfg(windows)]
{
let _ = from;
junction::create(&target, to).map_err(|e| {
CoreError::io(
format!(
"cannot recreate directory junction '{}' -> '{}' during copy",
to.display(),
target.display()
),
e,
)
})?;
}
#[cfg(not(any(unix, windows)))]
{
let _ = (target, to);
return Err(CoreError::io(
"cannot recreate symlink on this platform",
io::Error::new(io::ErrorKind::Unsupported, "symlinks unsupported"),
));
}
Ok(())
}
fn remove_copied_symlink(path: &Path) -> CoreResult<()> {
remove_symlink_path(path).map_err(|e| {
CoreError::io(
format!(
"cannot remove source symlink '{}' after copy: remove it manually and retry",
path.display()
),
e,
)
})
}
fn remove_symlink_path(path: &Path) -> io::Result<()> {
#[cfg(windows)]
{
junction::delete(path)
}
#[cfg(not(windows))]
{
fs::remove_file(path)
}
}
fn read_dir_link(path: &Path) -> io::Result<PathBuf> {
#[cfg(windows)]
{
junction::get_target(path)
}
#[cfg(not(windows))]
{
fs::read_link(path)
}
}
pub fn format_health_message(status: &CoordinationHealthStatus) -> Option<String> {
match status {
CoordinationHealthStatus::Healthy => None,
CoordinationHealthStatus::Embedded => None,
CoordinationHealthStatus::WorktreeMissing { expected_path } => Some(format!(
"Coordination worktree not found at {}. \
Run `ito init` to set it up.",
expected_path.display()
)),
CoordinationHealthStatus::BrokenSymlinks { broken } => {
let lines: Vec<String> = broken
.iter()
.map(|(link, target)| {
format!(
"Broken symlink: {} → {} (target does not exist). \
Run `ito init` to repair.",
link.display(),
target.display()
)
})
.collect();
Some(lines.join("\n"))
}
CoordinationHealthStatus::WrongTargets { mismatched } => {
let lines: Vec<String> = mismatched
.iter()
.map(|(link, actual, expected)| {
format!(
"{} points to {} but should point to {}. \
Run `ito init` to repair.",
link.display(),
actual.display(),
expected.display()
)
})
.collect();
Some(lines.join("\n"))
}
CoordinationHealthStatus::NotWired { dirs } => {
let lines: Vec<String> = dirs
.iter()
.map(|dir| {
format!(
"{} is a regular directory, not a symlink to the coordination worktree. \
Run `ito init` to wire symlinks.",
dir.display()
)
})
.collect();
Some(lines.join("\n"))
}
}
}
#[cfg(test)]
#[path = "coordination_tests.rs"]
mod tests;