1use std::fs;
10use std::io;
11use std::path::{Path, PathBuf};
12
13use ito_config::ito_dir::lexical_normalize;
14
15use ito_config::types::CoordinationStorage;
16
17use crate::errors::{CoreError, CoreResult};
18
19pub const COORDINATION_DIRS: &[&str] = &["changes", "specs", "modules", "workflows", "audit"];
21
22const COORDINATION_GITIGNORE_ENTRIES: &[&str] = &[
29 ".ito/changes",
30 ".ito/specs",
31 ".ito/modules",
32 ".ito/workflows",
33 ".ito/audit",
34];
35
36#[must_use]
43pub fn gitignore_entries() -> &'static [&'static str] {
44 COORDINATION_GITIGNORE_ENTRIES
45}
46
47pub fn create_dir_link(src: &Path, dst: &Path) -> io::Result<()> {
61 #[cfg(unix)]
62 {
63 std::os::unix::fs::symlink(src, dst)
64 }
65 #[cfg(windows)]
66 {
67 junction::create(src, dst)
68 }
69 #[cfg(not(any(unix, windows)))]
70 {
71 let _ = (src, dst);
72 Err(io::Error::new(
73 io::ErrorKind::Unsupported,
74 "directory symlinks are not supported on this platform",
75 ))
76 }
77}
78
79fn read_link_opt(path: &Path) -> io::Result<Option<PathBuf>> {
84 match read_dir_link(path) {
85 Ok(target) => Ok(Some(target)),
86 Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None),
88 Err(e) if e.kind() == io::ErrorKind::InvalidInput => Ok(None),
90 Err(_) if path.exists() => Ok(None),
92 Err(e) => Err(e),
93 }
94}
95
96pub fn wire_coordination_symlinks(ito_path: &Path, worktree_ito_path: &Path) -> CoreResult<()> {
116 for dir in COORDINATION_DIRS {
117 let src = ito_path.join(dir);
118 let dst = worktree_ito_path.join(dir);
119
120 let existing_link = read_link_opt(&src).map_err(|e| {
122 CoreError::io(
123 format!(
124 "cannot read symlink status of '{}': check filesystem permissions",
125 src.display()
126 ),
127 e,
128 )
129 })?;
130
131 if let Some(target) = existing_link {
132 let resolved_target = if target.is_absolute() {
135 lexical_normalize(&target)
136 } else {
137 lexical_normalize(&ito_path.join(&target))
140 };
141 let resolved_dst = lexical_normalize(&dst);
142
143 if resolved_target == resolved_dst || target == dst {
144 fs::create_dir_all(&dst).map_err(|e| {
147 CoreError::io(
148 format!(
149 "cannot create coordination directory '{}' for existing symlink '{}': ensure the worktree path is writable",
150 dst.display(),
151 src.display()
152 ),
153 e,
154 )
155 })?;
156 continue;
157 }
158
159 return Err(CoreError::process(format!(
160 "Coordination symlink '{}' points to '{}' but should point to '{}'. Delete or move the wrong symlink manually, then run `ito init` again.",
161 src.display(),
162 resolved_target.display(),
163 resolved_dst.display()
164 )));
165 } else if src.exists() {
166 if !src.is_dir() {
167 return Err(CoreError::process(format!(
168 "Coordination path '{}' exists but is not a directory or symlink. Move it aside, then run `ito init` again to wire '{}'.",
169 src.display(),
170 dst.display()
171 )));
172 }
173
174 if !is_dir_empty(&src)? {
175 return Err(CoreError::process(format!(
176 "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.",
177 src.display(),
178 dst.display()
179 )));
180 }
181
182 fs::remove_dir(&src).map_err(|e| {
183 CoreError::io(
184 format!(
185 "cannot remove empty coordination directory '{}': remove it manually and retry",
186 src.display()
187 ),
188 e,
189 )
190 })?;
191 }
192 fs::create_dir_all(&dst).map_err(|e| {
196 CoreError::io(
197 format!(
198 "cannot create coordination directory '{}': ensure the worktree path is \
199 writable",
200 dst.display()
201 ),
202 e,
203 )
204 })?;
205
206 create_dir_link(&dst, &src).map_err(|e| {
207 CoreError::io(
208 format!(
209 "cannot create symlink '{}' → '{}': on Linux/macOS ensure you have write \
210 permission to '{}'; on Windows you may need Developer Mode or elevated \
211 privileges",
212 src.display(),
213 dst.display(),
214 ito_path.display()
215 ),
216 e,
217 )
218 })?;
219 }
220
221 Ok(())
222}
223
224fn is_dir_empty(path: &Path) -> CoreResult<bool> {
225 let mut entries = fs::read_dir(path).map_err(|e| {
226 CoreError::io(
227 format!(
228 "cannot read coordination directory '{}' to verify it is empty: check filesystem permissions",
229 path.display()
230 ),
231 e,
232 )
233 })?;
234
235 Ok(entries.next().is_none())
236}
237
238fn copy_dir_recursive(src: &Path, dst: &Path) -> CoreResult<()> {
249 fs::create_dir_all(dst).map_err(|e| {
250 CoreError::io(
251 format!(
252 "cannot create directory '{}' during recursive copy: ensure the target \
253 path is writable",
254 dst.display()
255 ),
256 e,
257 )
258 })?;
259
260 let entries = fs::read_dir(src).map_err(|e| {
261 CoreError::io(
262 format!(
263 "cannot read directory '{}' during recursive copy: check filesystem \
264 permissions",
265 src.display()
266 ),
267 e,
268 )
269 })?;
270
271 for entry in entries {
272 let entry = entry.map_err(|e| {
273 CoreError::io(
274 format!(
275 "cannot read directory entry in '{}' during recursive copy",
276 src.display()
277 ),
278 e,
279 )
280 })?;
281 let from = entry.path();
282 let to = dst.join(entry.file_name());
283
284 let file_type = entry.file_type().map_err(|e| {
285 CoreError::io(
286 format!(
287 "cannot determine file type of '{}' during recursive copy",
288 from.display()
289 ),
290 e,
291 )
292 })?;
293
294 if file_type.is_symlink() {
295 let target = read_dir_link(&from).map_err(|e| {
296 CoreError::io(
297 format!(
298 "cannot read symlink '{}' during recursive copy",
299 from.display()
300 ),
301 e,
302 )
303 })?;
304 #[cfg(unix)]
305 std::os::unix::fs::symlink(&target, &to).map_err(|e| {
306 CoreError::io(
307 format!(
308 "cannot recreate symlink '{}' -> '{}' during recursive copy: ensure \
309 '{}' is writable",
310 to.display(),
311 target.display(),
312 dst.display()
313 ),
314 e,
315 )
316 })?;
317 #[cfg(windows)]
318 {
319 junction::create(&target, &to).map_err(|e| {
320 CoreError::io(
321 format!(
322 "cannot recreate directory junction '{}' -> '{}' during recursive \
323 copy: ensure '{}' is writable",
324 to.display(),
325 target.display(),
326 dst.display()
327 ),
328 e,
329 )
330 })?;
331 }
332 } else if file_type.is_dir() {
333 copy_dir_recursive(&from, &to)?;
334 } else {
335 fs::copy(&from, &to).map_err(|e| {
336 CoreError::io(
337 format!(
338 "cannot copy '{}' to '{}' during recursive copy: ensure '{}' is \
339 writable",
340 from.display(),
341 to.display(),
342 dst.display()
343 ),
344 e,
345 )
346 })?;
347 }
348 }
349
350 Ok(())
351}
352
353pub fn update_gitignore_for_symlinks(project_root: &Path) -> CoreResult<()> {
375 let gitignore_path = project_root.join(".gitignore");
376
377 let existing = if gitignore_path.exists() {
378 fs::read_to_string(&gitignore_path).map_err(|e| {
379 CoreError::io(
380 format!(
381 "cannot read '{}': check filesystem permissions",
382 gitignore_path.display()
383 ),
384 e,
385 )
386 })?
387 } else {
388 String::new()
389 };
390
391 let missing: Vec<&str> = gitignore_entries()
393 .iter()
394 .copied()
395 .filter(|line| !existing.lines().any(|l| l.trim() == *line))
396 .collect();
397
398 if missing.is_empty() {
399 return Ok(());
400 }
401
402 let mut content = existing;
403
404 if !content.is_empty() && !content.ends_with('\n') {
406 content.push('\n');
407 }
408
409 content.push_str("\n# Ito coordination worktree symlinks\n");
410 for line in &missing {
411 content.push_str(line);
412 content.push('\n');
413 }
414
415 fs::write(&gitignore_path, &content).map_err(|e| {
416 CoreError::io(
417 format!(
418 "cannot write '{}': check filesystem permissions",
419 gitignore_path.display()
420 ),
421 e,
422 )
423 })?;
424
425 Ok(())
426}
427
428pub fn remove_coordination_symlinks(ito_path: &Path, worktree_ito_path: &Path) -> CoreResult<()> {
448 for dir in COORDINATION_DIRS {
449 let link_path = ito_path.join(dir);
450 let worktree_dir = worktree_ito_path.join(dir);
451
452 let existing_link = read_link_opt(&link_path).map_err(|e| {
453 CoreError::io(
454 format!(
455 "cannot read symlink status of '{}': check filesystem permissions",
456 link_path.display()
457 ),
458 e,
459 )
460 })?;
461
462 let Some(_target) = existing_link else {
463 continue;
465 };
466
467 fs::remove_file(&link_path).map_err(|e| {
469 CoreError::io(
470 format!(
471 "cannot remove symlink '{}': delete it manually and retry",
472 link_path.display()
473 ),
474 e,
475 )
476 })?;
477
478 fs::create_dir_all(&link_path).map_err(|e| {
480 CoreError::io(
481 format!(
482 "cannot create directory '{}' after removing symlink: check filesystem \
483 permissions",
484 link_path.display()
485 ),
486 e,
487 )
488 })?;
489
490 if worktree_dir.exists() {
492 let entries = fs::read_dir(&worktree_dir).map_err(|e| {
493 CoreError::io(
494 format!(
495 "cannot read worktree directory '{}' during symlink teardown",
496 worktree_dir.display()
497 ),
498 e,
499 )
500 })?;
501
502 for entry in entries {
503 let entry = entry.map_err(|e| {
504 CoreError::io(
505 format!(
506 "cannot read directory entry in '{}' during teardown",
507 worktree_dir.display()
508 ),
509 e,
510 )
511 })?;
512 let from = entry.path();
513 let to = link_path.join(entry.file_name());
514 move_entry_with_fallback(&from, &to, &link_path)?;
515 }
516 }
517 }
518
519 Ok(())
520}
521
522#[derive(Debug, PartialEq)]
529pub enum CoordinationHealthStatus {
530 Healthy,
532 Embedded,
534 WorktreeMissing {
536 expected_path: PathBuf,
538 },
539 BrokenSymlinks {
541 broken: Vec<(PathBuf, PathBuf)>,
543 },
544 WrongTargets {
546 mismatched: Vec<(PathBuf, PathBuf, PathBuf)>,
548 },
549 NotWired {
551 dirs: Vec<PathBuf>,
553 },
554}
555
556pub fn check_coordination_health(
582 ito_path: &Path,
583 worktree_ito_path: &Path,
584 storage: &CoordinationStorage,
585) -> CoordinationHealthStatus {
586 if *storage == CoordinationStorage::Embedded {
587 return CoordinationHealthStatus::Embedded;
588 }
589
590 if !worktree_ito_path.exists() {
591 return CoordinationHealthStatus::WorktreeMissing {
592 expected_path: worktree_ito_path.to_path_buf(),
593 };
594 }
595
596 let mut broken: Vec<(PathBuf, PathBuf)> = Vec::new();
597 let mut mismatched: Vec<(PathBuf, PathBuf, PathBuf)> = Vec::new();
598 let mut not_wired: Vec<PathBuf> = Vec::new();
599
600 for dir in COORDINATION_DIRS {
601 let link_path = ito_path.join(dir);
602 let expected_target = lexical_normalize(&worktree_ito_path.join(dir));
603
604 if !link_path.exists() && fs::read_link(&link_path).is_err() {
605 not_wired.push(link_path);
606 continue;
607 }
608
609 match read_dir_link(&link_path) {
610 Ok(target) => {
611 let resolved = if target.is_absolute() {
613 lexical_normalize(&target)
614 } else {
615 lexical_normalize(&ito_path.join(&target))
616 };
617 if !resolved.exists() {
618 broken.push((link_path, target));
619 } else if resolved != expected_target {
620 mismatched.push((link_path, resolved, expected_target));
621 }
622 }
623 Err(e) if e.kind() == io::ErrorKind::InvalidInput => {
624 if link_path.exists() {
626 not_wired.push(link_path);
627 }
628 }
629 Err(_) => {
630 if link_path.exists() {
633 not_wired.push(link_path);
634 }
635 }
638 }
639 }
640
641 if !broken.is_empty() {
642 return CoordinationHealthStatus::BrokenSymlinks { broken };
643 }
644
645 if !mismatched.is_empty() {
646 return CoordinationHealthStatus::WrongTargets { mismatched };
647 }
648
649 if !not_wired.is_empty() {
650 return CoordinationHealthStatus::NotWired { dirs: not_wired };
651 }
652
653 CoordinationHealthStatus::Healthy
654}
655
656fn move_entry_with_fallback(from: &Path, to: &Path, target_root: &Path) -> CoreResult<()> {
657 let rename_err = match fs::rename(from, to) {
658 Ok(()) => return Ok(()),
659 Err(e) => e,
660 };
661
662 if rename_err.kind() != io::ErrorKind::CrossesDevices {
663 return Err(CoreError::io(
664 format!(
665 "cannot move '{}' to '{}' during coordination teardown: ensure both paths are accessible and retry",
666 from.display(),
667 to.display()
668 ),
669 rename_err,
670 ));
671 }
672
673 let file_type = fs::symlink_metadata(from).map_err(|e| {
674 CoreError::io(
675 format!(
676 "cannot inspect '{}' during cross-filesystem coordination teardown",
677 from.display()
678 ),
679 e,
680 )
681 })?;
682
683 if file_type.file_type().is_symlink() {
684 copy_symlink(from, to)?;
685 remove_copied_symlink(from)?;
686 } else if file_type.is_dir() {
687 copy_dir_recursive(from, to)?;
688 fs::remove_dir_all(from).map_err(|e| {
689 CoreError::io(
690 format!(
691 "cannot remove source directory '{}' after cross-filesystem teardown copy: remove it manually and retry",
692 from.display()
693 ),
694 e,
695 )
696 })?;
697 } else {
698 fs::copy(from, to).map_err(|e| {
699 CoreError::io(
700 format!(
701 "cannot copy '{}' to '{}' during coordination teardown: ensure '{}' is writable",
702 from.display(),
703 to.display(),
704 target_root.display()
705 ),
706 e,
707 )
708 })?;
709 fs::remove_file(from).map_err(|e| {
710 CoreError::io(
711 format!(
712 "cannot remove source file '{}' after cross-filesystem teardown copy: remove it manually and retry",
713 from.display()
714 ),
715 e,
716 )
717 })?;
718 }
719
720 Ok(())
721}
722
723fn copy_symlink(from: &Path, to: &Path) -> CoreResult<()> {
724 let target = read_dir_link(from).map_err(|e| {
725 CoreError::io(
726 format!("cannot read symlink '{}' during copy", from.display()),
727 e,
728 )
729 })?;
730
731 #[cfg(unix)]
732 {
733 std::os::unix::fs::symlink(&target, to).map_err(|e| {
734 CoreError::io(
735 format!(
736 "cannot recreate symlink '{}' -> '{}' during copy",
737 to.display(),
738 target.display()
739 ),
740 e,
741 )
742 })?;
743 }
744
745 #[cfg(windows)]
746 {
747 let _ = from;
748 junction::create(&target, to).map_err(|e| {
749 CoreError::io(
750 format!(
751 "cannot recreate directory junction '{}' -> '{}' during copy",
752 to.display(),
753 target.display()
754 ),
755 e,
756 )
757 })?;
758 }
759
760 #[cfg(not(any(unix, windows)))]
761 {
762 let _ = (target, to);
763 return Err(CoreError::io(
764 "cannot recreate symlink on this platform",
765 io::Error::new(io::ErrorKind::Unsupported, "symlinks unsupported"),
766 ));
767 }
768
769 Ok(())
770}
771
772fn remove_copied_symlink(path: &Path) -> CoreResult<()> {
773 remove_symlink_path(path).map_err(|e| {
774 CoreError::io(
775 format!(
776 "cannot remove source symlink '{}' after copy: remove it manually and retry",
777 path.display()
778 ),
779 e,
780 )
781 })
782}
783
784fn remove_symlink_path(path: &Path) -> io::Result<()> {
785 #[cfg(windows)]
786 {
787 junction::delete(path)
788 }
789
790 #[cfg(not(windows))]
791 {
792 fs::remove_file(path)
793 }
794}
795
796fn read_dir_link(path: &Path) -> io::Result<PathBuf> {
797 #[cfg(windows)]
798 {
799 junction::get_target(path)
800 }
801
802 #[cfg(not(windows))]
803 {
804 fs::read_link(path)
805 }
806}
807
808pub fn format_health_message(status: &CoordinationHealthStatus) -> Option<String> {
815 match status {
816 CoordinationHealthStatus::Healthy => None,
817 CoordinationHealthStatus::Embedded => None,
818 CoordinationHealthStatus::WorktreeMissing { expected_path } => Some(format!(
819 "Coordination worktree not found at {}. \
820 Run `ito init` to set it up.",
821 expected_path.display()
822 )),
823 CoordinationHealthStatus::BrokenSymlinks { broken } => {
824 let lines: Vec<String> = broken
825 .iter()
826 .map(|(link, target)| {
827 format!(
828 "Broken symlink: {} → {} (target does not exist). \
829 Run `ito init` to repair.",
830 link.display(),
831 target.display()
832 )
833 })
834 .collect();
835 Some(lines.join("\n"))
836 }
837 CoordinationHealthStatus::WrongTargets { mismatched } => {
838 let lines: Vec<String> = mismatched
839 .iter()
840 .map(|(link, actual, expected)| {
841 format!(
842 "{} points to {} but should point to {}. \
843 Run `ito init` to repair.",
844 link.display(),
845 actual.display(),
846 expected.display()
847 )
848 })
849 .collect();
850 Some(lines.join("\n"))
851 }
852 CoordinationHealthStatus::NotWired { dirs } => {
853 let lines: Vec<String> = dirs
854 .iter()
855 .map(|dir| {
856 format!(
857 "{} is a regular directory, not a symlink to the coordination worktree. \
858 Run `ito init` to wire symlinks.",
859 dir.display()
860 )
861 })
862 .collect();
863 Some(lines.join("\n"))
864 }
865 }
866}
867
868#[cfg(test)]
869#[path = "coordination_tests.rs"]
870mod tests;