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
22pub fn create_dir_link(src: &Path, dst: &Path) -> io::Result<()> {
36 #[cfg(unix)]
37 {
38 std::os::unix::fs::symlink(src, dst)
39 }
40 #[cfg(windows)]
41 {
42 junction::create(src, dst)
43 }
44 #[cfg(not(any(unix, windows)))]
45 {
46 let _ = (src, dst);
47 Err(io::Error::new(
48 io::ErrorKind::Unsupported,
49 "directory symlinks are not supported on this platform",
50 ))
51 }
52}
53
54fn read_link_opt(path: &Path) -> io::Result<Option<PathBuf>> {
59 match read_dir_link(path) {
60 Ok(target) => Ok(Some(target)),
61 Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None),
63 Err(e) if e.kind() == io::ErrorKind::InvalidInput => Ok(None),
65 Err(_) if path.exists() => Ok(None),
67 Err(e) => Err(e),
68 }
69}
70
71pub fn wire_coordination_symlinks(ito_path: &Path, worktree_ito_path: &Path) -> CoreResult<()> {
91 for dir in COORDINATION_DIRS {
92 let src = ito_path.join(dir);
93 let dst = worktree_ito_path.join(dir);
94
95 let existing_link = read_link_opt(&src).map_err(|e| {
97 CoreError::io(
98 format!(
99 "cannot read symlink status of '{}': check filesystem permissions",
100 src.display()
101 ),
102 e,
103 )
104 })?;
105
106 if let Some(target) = existing_link {
107 let resolved_target = if target.is_absolute() {
110 lexical_normalize(&target)
111 } else {
112 lexical_normalize(&ito_path.join(&target))
115 };
116 let resolved_dst = lexical_normalize(&dst);
117
118 if resolved_target == resolved_dst || target == dst {
119 continue;
121 }
122
123 remove_symlink_path(&src).map_err(|e| {
126 CoreError::io(
127 format!(
128 "cannot remove stale symlink '{}': delete it manually and retry",
129 src.display()
130 ),
131 e,
132 )
133 })?;
134 } else if src.exists() {
135 migrate_dir_to_worktree(&src, &dst)?;
137 }
138 fs::create_dir_all(&dst).map_err(|e| {
142 CoreError::io(
143 format!(
144 "cannot create coordination directory '{}': ensure the worktree path is \
145 writable",
146 dst.display()
147 ),
148 e,
149 )
150 })?;
151
152 create_dir_link(&dst, &src).map_err(|e| {
153 CoreError::io(
154 format!(
155 "cannot create symlink '{}' → '{}': on Linux/macOS ensure you have write \
156 permission to '{}'; on Windows you may need Developer Mode or elevated \
157 privileges",
158 src.display(),
159 dst.display(),
160 ito_path.display()
161 ),
162 e,
163 )
164 })?;
165 }
166
167 Ok(())
168}
169
170fn migrate_dir_to_worktree(src_dir: &Path, dst_dir: &Path) -> CoreResult<()> {
182 fs::create_dir_all(dst_dir).map_err(|e| {
183 CoreError::io(
184 format!(
185 "cannot create target directory '{}' for content migration: ensure the \
186 worktree path is writable",
187 dst_dir.display()
188 ),
189 e,
190 )
191 })?;
192
193 let entries = fs::read_dir(src_dir).map_err(|e| {
194 CoreError::io(
195 format!(
196 "cannot read directory '{}' for migration: check filesystem permissions",
197 src_dir.display()
198 ),
199 e,
200 )
201 })?;
202
203 for entry in entries {
204 let entry = entry.map_err(|e| {
205 CoreError::io(
206 format!(
207 "cannot read directory entry in '{}' during migration",
208 src_dir.display()
209 ),
210 e,
211 )
212 })?;
213 let from = entry.path();
214 let to = dst_dir.join(entry.file_name());
215
216 let rename_err = match fs::rename(&from, &to) {
217 Ok(()) => continue,
218 Err(e) => e,
219 };
220
221 let is_cross_device = rename_err.kind() == io::ErrorKind::CrossesDevices;
225 if !is_cross_device {
226 return Err(CoreError::io(
227 format!(
228 "cannot move '{}' to '{}' during coordination migration: ensure both \
229 paths are accessible and retry",
230 from.display(),
231 to.display()
232 ),
233 rename_err,
234 ));
235 }
236
237 let file_type = entry.file_type().map_err(|e| {
238 CoreError::io(
239 format!(
240 "cannot determine file type of '{}' during cross-filesystem migration",
241 from.display()
242 ),
243 e,
244 )
245 })?;
246
247 if file_type.is_symlink() {
248 copy_symlink(&from, &to)?;
249 remove_copied_symlink(&from)?;
250 } else if file_type.is_dir() {
251 copy_dir_recursive(&from, &to)?;
252 fs::remove_dir_all(&from).map_err(|e| {
253 CoreError::io(
254 format!(
255 "cannot remove source directory '{}' after cross-filesystem copy: \
256 remove it manually and retry",
257 from.display()
258 ),
259 e,
260 )
261 })?;
262 } else {
263 fs::copy(&from, &to).map_err(|e| {
264 CoreError::io(
265 format!(
266 "cannot copy '{}' to '{}' during cross-filesystem migration: ensure \
267 '{}' is writable",
268 from.display(),
269 to.display(),
270 dst_dir.display()
271 ),
272 e,
273 )
274 })?;
275 fs::remove_file(&from).map_err(|e| {
276 CoreError::io(
277 format!(
278 "cannot remove source file '{}' after cross-filesystem copy: \
279 remove it manually and retry",
280 from.display()
281 ),
282 e,
283 )
284 })?;
285 }
286 }
287
288 fs::remove_dir(src_dir).map_err(|e| {
289 CoreError::io(
290 format!(
291 "cannot remove now-empty directory '{}' after migration: remove it manually \
292 and retry",
293 src_dir.display()
294 ),
295 e,
296 )
297 })?;
298
299 Ok(())
300}
301
302fn copy_dir_recursive(src: &Path, dst: &Path) -> CoreResult<()> {
313 fs::create_dir_all(dst).map_err(|e| {
314 CoreError::io(
315 format!(
316 "cannot create directory '{}' during recursive copy: ensure the target \
317 path is writable",
318 dst.display()
319 ),
320 e,
321 )
322 })?;
323
324 let entries = fs::read_dir(src).map_err(|e| {
325 CoreError::io(
326 format!(
327 "cannot read directory '{}' during recursive copy: check filesystem \
328 permissions",
329 src.display()
330 ),
331 e,
332 )
333 })?;
334
335 for entry in entries {
336 let entry = entry.map_err(|e| {
337 CoreError::io(
338 format!(
339 "cannot read directory entry in '{}' during recursive copy",
340 src.display()
341 ),
342 e,
343 )
344 })?;
345 let from = entry.path();
346 let to = dst.join(entry.file_name());
347
348 let file_type = entry.file_type().map_err(|e| {
349 CoreError::io(
350 format!(
351 "cannot determine file type of '{}' during recursive copy",
352 from.display()
353 ),
354 e,
355 )
356 })?;
357
358 if file_type.is_symlink() {
359 let target = read_dir_link(&from).map_err(|e| {
360 CoreError::io(
361 format!(
362 "cannot read symlink '{}' during recursive copy",
363 from.display()
364 ),
365 e,
366 )
367 })?;
368 #[cfg(unix)]
369 std::os::unix::fs::symlink(&target, &to).map_err(|e| {
370 CoreError::io(
371 format!(
372 "cannot recreate symlink '{}' -> '{}' during recursive copy: ensure \
373 '{}' is writable",
374 to.display(),
375 target.display(),
376 dst.display()
377 ),
378 e,
379 )
380 })?;
381 #[cfg(windows)]
382 {
383 junction::create(&target, &to).map_err(|e| {
384 CoreError::io(
385 format!(
386 "cannot recreate directory junction '{}' -> '{}' during recursive \
387 copy: ensure '{}' is writable",
388 to.display(),
389 target.display(),
390 dst.display()
391 ),
392 e,
393 )
394 })?;
395 }
396 } else if file_type.is_dir() {
397 copy_dir_recursive(&from, &to)?;
398 } else {
399 fs::copy(&from, &to).map_err(|e| {
400 CoreError::io(
401 format!(
402 "cannot copy '{}' to '{}' during recursive copy: ensure '{}' is \
403 writable",
404 from.display(),
405 to.display(),
406 dst.display()
407 ),
408 e,
409 )
410 })?;
411 }
412 }
413
414 Ok(())
415}
416
417pub fn update_gitignore_for_symlinks(project_root: &Path) -> CoreResult<()> {
439 let gitignore_path = project_root.join(".gitignore");
440
441 let existing = if gitignore_path.exists() {
442 fs::read_to_string(&gitignore_path).map_err(|e| {
443 CoreError::io(
444 format!(
445 "cannot read '{}': check filesystem permissions",
446 gitignore_path.display()
447 ),
448 e,
449 )
450 })?
451 } else {
452 String::new()
453 };
454
455 let desired_lines: Vec<String> = COORDINATION_DIRS
457 .iter()
458 .map(|dir| format!(".ito/{dir}"))
459 .collect();
460
461 let missing: Vec<&str> = desired_lines
463 .iter()
464 .filter(|line| !existing.lines().any(|l| l.trim() == line.as_str()))
465 .map(String::as_str)
466 .collect();
467
468 if missing.is_empty() {
469 return Ok(());
470 }
471
472 let mut content = existing;
473
474 if !content.is_empty() && !content.ends_with('\n') {
476 content.push('\n');
477 }
478
479 content.push_str("\n# Ito coordination worktree symlinks\n");
480 for line in &missing {
481 content.push_str(line);
482 content.push('\n');
483 }
484
485 fs::write(&gitignore_path, &content).map_err(|e| {
486 CoreError::io(
487 format!(
488 "cannot write '{}': check filesystem permissions",
489 gitignore_path.display()
490 ),
491 e,
492 )
493 })?;
494
495 Ok(())
496}
497
498pub fn remove_coordination_symlinks(ito_path: &Path, worktree_ito_path: &Path) -> CoreResult<()> {
518 for dir in COORDINATION_DIRS {
519 let link_path = ito_path.join(dir);
520 let worktree_dir = worktree_ito_path.join(dir);
521
522 let existing_link = read_link_opt(&link_path).map_err(|e| {
523 CoreError::io(
524 format!(
525 "cannot read symlink status of '{}': check filesystem permissions",
526 link_path.display()
527 ),
528 e,
529 )
530 })?;
531
532 let Some(_target) = existing_link else {
533 continue;
535 };
536
537 fs::remove_file(&link_path).map_err(|e| {
539 CoreError::io(
540 format!(
541 "cannot remove symlink '{}': delete it manually and retry",
542 link_path.display()
543 ),
544 e,
545 )
546 })?;
547
548 fs::create_dir_all(&link_path).map_err(|e| {
550 CoreError::io(
551 format!(
552 "cannot create directory '{}' after removing symlink: check filesystem \
553 permissions",
554 link_path.display()
555 ),
556 e,
557 )
558 })?;
559
560 if worktree_dir.exists() {
562 let entries = fs::read_dir(&worktree_dir).map_err(|e| {
563 CoreError::io(
564 format!(
565 "cannot read worktree directory '{}' during symlink teardown",
566 worktree_dir.display()
567 ),
568 e,
569 )
570 })?;
571
572 for entry in entries {
573 let entry = entry.map_err(|e| {
574 CoreError::io(
575 format!(
576 "cannot read directory entry in '{}' during teardown",
577 worktree_dir.display()
578 ),
579 e,
580 )
581 })?;
582 let from = entry.path();
583 let to = link_path.join(entry.file_name());
584 move_entry_with_fallback(&from, &to, &link_path)?;
585 }
586 }
587 }
588
589 Ok(())
590}
591
592#[derive(Debug, PartialEq)]
599pub enum CoordinationHealthStatus {
600 Healthy,
602 Embedded,
604 WorktreeMissing {
606 expected_path: PathBuf,
608 },
609 BrokenSymlinks {
611 broken: Vec<(PathBuf, PathBuf)>,
613 },
614 NotWired {
616 dirs: Vec<PathBuf>,
618 },
619}
620
621pub fn check_coordination_health(
644 ito_path: &Path,
645 worktree_ito_path: &Path,
646 storage: &CoordinationStorage,
647) -> CoordinationHealthStatus {
648 if *storage == CoordinationStorage::Embedded {
649 return CoordinationHealthStatus::Embedded;
650 }
651
652 if !worktree_ito_path.exists() {
653 return CoordinationHealthStatus::WorktreeMissing {
654 expected_path: worktree_ito_path.to_path_buf(),
655 };
656 }
657
658 let mut broken: Vec<(PathBuf, PathBuf)> = Vec::new();
659 let mut not_wired: Vec<PathBuf> = Vec::new();
660
661 for dir in COORDINATION_DIRS {
662 let link_path = ito_path.join(dir);
663
664 if !link_path.exists() && fs::read_link(&link_path).is_err() {
665 not_wired.push(link_path);
666 continue;
667 }
668
669 match read_dir_link(&link_path) {
670 Ok(target) => {
671 let resolved = if target.is_absolute() {
673 target.clone()
674 } else {
675 ito_path.join(&target)
676 };
677 if !resolved.exists() {
678 broken.push((link_path, target));
679 }
680 }
681 Err(e) if e.kind() == io::ErrorKind::InvalidInput => {
682 if link_path.exists() {
684 not_wired.push(link_path);
685 }
686 }
687 Err(_) => {
688 if link_path.exists() {
691 not_wired.push(link_path);
692 }
693 }
696 }
697 }
698
699 if !broken.is_empty() {
700 return CoordinationHealthStatus::BrokenSymlinks { broken };
701 }
702
703 if !not_wired.is_empty() {
704 return CoordinationHealthStatus::NotWired { dirs: not_wired };
705 }
706
707 CoordinationHealthStatus::Healthy
708}
709
710fn move_entry_with_fallback(from: &Path, to: &Path, target_root: &Path) -> CoreResult<()> {
711 let rename_err = match fs::rename(from, to) {
712 Ok(()) => return Ok(()),
713 Err(e) => e,
714 };
715
716 if rename_err.kind() != io::ErrorKind::CrossesDevices {
717 return Err(CoreError::io(
718 format!(
719 "cannot move '{}' to '{}' during coordination teardown: ensure both paths are accessible and retry",
720 from.display(),
721 to.display()
722 ),
723 rename_err,
724 ));
725 }
726
727 let file_type = fs::symlink_metadata(from).map_err(|e| {
728 CoreError::io(
729 format!(
730 "cannot inspect '{}' during cross-filesystem coordination teardown",
731 from.display()
732 ),
733 e,
734 )
735 })?;
736
737 if file_type.file_type().is_symlink() {
738 copy_symlink(from, to)?;
739 remove_copied_symlink(from)?;
740 } else if file_type.is_dir() {
741 copy_dir_recursive(from, to)?;
742 fs::remove_dir_all(from).map_err(|e| {
743 CoreError::io(
744 format!(
745 "cannot remove source directory '{}' after cross-filesystem teardown copy: remove it manually and retry",
746 from.display()
747 ),
748 e,
749 )
750 })?;
751 } else {
752 fs::copy(from, to).map_err(|e| {
753 CoreError::io(
754 format!(
755 "cannot copy '{}' to '{}' during coordination teardown: ensure '{}' is writable",
756 from.display(),
757 to.display(),
758 target_root.display()
759 ),
760 e,
761 )
762 })?;
763 fs::remove_file(from).map_err(|e| {
764 CoreError::io(
765 format!(
766 "cannot remove source file '{}' after cross-filesystem teardown copy: remove it manually and retry",
767 from.display()
768 ),
769 e,
770 )
771 })?;
772 }
773
774 Ok(())
775}
776
777fn copy_symlink(from: &Path, to: &Path) -> CoreResult<()> {
778 let target = read_dir_link(from).map_err(|e| {
779 CoreError::io(
780 format!("cannot read symlink '{}' during copy", from.display()),
781 e,
782 )
783 })?;
784
785 #[cfg(unix)]
786 {
787 std::os::unix::fs::symlink(&target, to).map_err(|e| {
788 CoreError::io(
789 format!(
790 "cannot recreate symlink '{}' -> '{}' during copy",
791 to.display(),
792 target.display()
793 ),
794 e,
795 )
796 })?;
797 }
798
799 #[cfg(windows)]
800 {
801 let _ = from;
802 junction::create(&target, to).map_err(|e| {
803 CoreError::io(
804 format!(
805 "cannot recreate directory junction '{}' -> '{}' during copy",
806 to.display(),
807 target.display()
808 ),
809 e,
810 )
811 })?;
812 }
813
814 #[cfg(not(any(unix, windows)))]
815 {
816 let _ = (target, to);
817 return Err(CoreError::io(
818 "cannot recreate symlink on this platform",
819 io::Error::new(io::ErrorKind::Unsupported, "symlinks unsupported"),
820 ));
821 }
822
823 Ok(())
824}
825
826fn remove_copied_symlink(path: &Path) -> CoreResult<()> {
827 remove_symlink_path(path).map_err(|e| {
828 CoreError::io(
829 format!(
830 "cannot remove source symlink '{}' after copy: remove it manually and retry",
831 path.display()
832 ),
833 e,
834 )
835 })
836}
837
838fn remove_symlink_path(path: &Path) -> io::Result<()> {
839 #[cfg(windows)]
840 {
841 junction::delete(path)
842 }
843
844 #[cfg(not(windows))]
845 {
846 fs::remove_file(path)
847 }
848}
849
850fn read_dir_link(path: &Path) -> io::Result<PathBuf> {
851 #[cfg(windows)]
852 {
853 junction::get_target(path)
854 }
855
856 #[cfg(not(windows))]
857 {
858 fs::read_link(path)
859 }
860}
861
862pub fn format_health_message(status: &CoordinationHealthStatus) -> Option<String> {
869 match status {
870 CoordinationHealthStatus::Healthy => None,
871 CoordinationHealthStatus::Embedded => None,
872 CoordinationHealthStatus::WorktreeMissing { expected_path } => Some(format!(
873 "Coordination worktree not found at {}. \
874 Run `ito init` to set it up.",
875 expected_path.display()
876 )),
877 CoordinationHealthStatus::BrokenSymlinks { broken } => {
878 let lines: Vec<String> = broken
879 .iter()
880 .map(|(link, target)| {
881 format!(
882 "Broken symlink: {} → {} (target does not exist). \
883 Run `ito init` to repair.",
884 link.display(),
885 target.display()
886 )
887 })
888 .collect();
889 Some(lines.join("\n"))
890 }
891 CoordinationHealthStatus::NotWired { dirs } => {
892 let lines: Vec<String> = dirs
893 .iter()
894 .map(|dir| {
895 format!(
896 "{} is a regular directory, not a symlink to the coordination worktree. \
897 Run `ito init` to wire symlinks.",
898 dir.display()
899 )
900 })
901 .collect();
902 Some(lines.join("\n"))
903 }
904 }
905}
906
907#[cfg(test)]
908#[path = "coordination_tests.rs"]
909mod tests;