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 WrongTargets {
616 mismatched: Vec<(PathBuf, PathBuf, PathBuf)>,
618 },
619 NotWired {
621 dirs: Vec<PathBuf>,
623 },
624}
625
626pub fn check_coordination_health(
652 ito_path: &Path,
653 worktree_ito_path: &Path,
654 storage: &CoordinationStorage,
655) -> CoordinationHealthStatus {
656 if *storage == CoordinationStorage::Embedded {
657 return CoordinationHealthStatus::Embedded;
658 }
659
660 if !worktree_ito_path.exists() {
661 return CoordinationHealthStatus::WorktreeMissing {
662 expected_path: worktree_ito_path.to_path_buf(),
663 };
664 }
665
666 let mut broken: Vec<(PathBuf, PathBuf)> = Vec::new();
667 let mut mismatched: Vec<(PathBuf, PathBuf, PathBuf)> = Vec::new();
668 let mut not_wired: Vec<PathBuf> = Vec::new();
669
670 for dir in COORDINATION_DIRS {
671 let link_path = ito_path.join(dir);
672 let expected_target = lexical_normalize(&worktree_ito_path.join(dir));
673
674 if !link_path.exists() && fs::read_link(&link_path).is_err() {
675 not_wired.push(link_path);
676 continue;
677 }
678
679 match read_dir_link(&link_path) {
680 Ok(target) => {
681 let resolved = if target.is_absolute() {
683 lexical_normalize(&target)
684 } else {
685 lexical_normalize(&ito_path.join(&target))
686 };
687 if !resolved.exists() {
688 broken.push((link_path, target));
689 } else if resolved != expected_target {
690 mismatched.push((link_path, resolved, expected_target));
691 }
692 }
693 Err(e) if e.kind() == io::ErrorKind::InvalidInput => {
694 if link_path.exists() {
696 not_wired.push(link_path);
697 }
698 }
699 Err(_) => {
700 if link_path.exists() {
703 not_wired.push(link_path);
704 }
705 }
708 }
709 }
710
711 if !broken.is_empty() {
712 return CoordinationHealthStatus::BrokenSymlinks { broken };
713 }
714
715 if !mismatched.is_empty() {
716 return CoordinationHealthStatus::WrongTargets { mismatched };
717 }
718
719 if !not_wired.is_empty() {
720 return CoordinationHealthStatus::NotWired { dirs: not_wired };
721 }
722
723 CoordinationHealthStatus::Healthy
724}
725
726fn move_entry_with_fallback(from: &Path, to: &Path, target_root: &Path) -> CoreResult<()> {
727 let rename_err = match fs::rename(from, to) {
728 Ok(()) => return Ok(()),
729 Err(e) => e,
730 };
731
732 if rename_err.kind() != io::ErrorKind::CrossesDevices {
733 return Err(CoreError::io(
734 format!(
735 "cannot move '{}' to '{}' during coordination teardown: ensure both paths are accessible and retry",
736 from.display(),
737 to.display()
738 ),
739 rename_err,
740 ));
741 }
742
743 let file_type = fs::symlink_metadata(from).map_err(|e| {
744 CoreError::io(
745 format!(
746 "cannot inspect '{}' during cross-filesystem coordination teardown",
747 from.display()
748 ),
749 e,
750 )
751 })?;
752
753 if file_type.file_type().is_symlink() {
754 copy_symlink(from, to)?;
755 remove_copied_symlink(from)?;
756 } else if file_type.is_dir() {
757 copy_dir_recursive(from, to)?;
758 fs::remove_dir_all(from).map_err(|e| {
759 CoreError::io(
760 format!(
761 "cannot remove source directory '{}' after cross-filesystem teardown copy: remove it manually and retry",
762 from.display()
763 ),
764 e,
765 )
766 })?;
767 } else {
768 fs::copy(from, to).map_err(|e| {
769 CoreError::io(
770 format!(
771 "cannot copy '{}' to '{}' during coordination teardown: ensure '{}' is writable",
772 from.display(),
773 to.display(),
774 target_root.display()
775 ),
776 e,
777 )
778 })?;
779 fs::remove_file(from).map_err(|e| {
780 CoreError::io(
781 format!(
782 "cannot remove source file '{}' after cross-filesystem teardown copy: remove it manually and retry",
783 from.display()
784 ),
785 e,
786 )
787 })?;
788 }
789
790 Ok(())
791}
792
793fn copy_symlink(from: &Path, to: &Path) -> CoreResult<()> {
794 let target = read_dir_link(from).map_err(|e| {
795 CoreError::io(
796 format!("cannot read symlink '{}' during copy", from.display()),
797 e,
798 )
799 })?;
800
801 #[cfg(unix)]
802 {
803 std::os::unix::fs::symlink(&target, to).map_err(|e| {
804 CoreError::io(
805 format!(
806 "cannot recreate symlink '{}' -> '{}' during copy",
807 to.display(),
808 target.display()
809 ),
810 e,
811 )
812 })?;
813 }
814
815 #[cfg(windows)]
816 {
817 let _ = from;
818 junction::create(&target, to).map_err(|e| {
819 CoreError::io(
820 format!(
821 "cannot recreate directory junction '{}' -> '{}' during copy",
822 to.display(),
823 target.display()
824 ),
825 e,
826 )
827 })?;
828 }
829
830 #[cfg(not(any(unix, windows)))]
831 {
832 let _ = (target, to);
833 return Err(CoreError::io(
834 "cannot recreate symlink on this platform",
835 io::Error::new(io::ErrorKind::Unsupported, "symlinks unsupported"),
836 ));
837 }
838
839 Ok(())
840}
841
842fn remove_copied_symlink(path: &Path) -> CoreResult<()> {
843 remove_symlink_path(path).map_err(|e| {
844 CoreError::io(
845 format!(
846 "cannot remove source symlink '{}' after copy: remove it manually and retry",
847 path.display()
848 ),
849 e,
850 )
851 })
852}
853
854fn remove_symlink_path(path: &Path) -> io::Result<()> {
855 #[cfg(windows)]
856 {
857 junction::delete(path)
858 }
859
860 #[cfg(not(windows))]
861 {
862 fs::remove_file(path)
863 }
864}
865
866fn read_dir_link(path: &Path) -> io::Result<PathBuf> {
867 #[cfg(windows)]
868 {
869 junction::get_target(path)
870 }
871
872 #[cfg(not(windows))]
873 {
874 fs::read_link(path)
875 }
876}
877
878pub fn format_health_message(status: &CoordinationHealthStatus) -> Option<String> {
885 match status {
886 CoordinationHealthStatus::Healthy => None,
887 CoordinationHealthStatus::Embedded => None,
888 CoordinationHealthStatus::WorktreeMissing { expected_path } => Some(format!(
889 "Coordination worktree not found at {}. \
890 Run `ito init` to set it up.",
891 expected_path.display()
892 )),
893 CoordinationHealthStatus::BrokenSymlinks { broken } => {
894 let lines: Vec<String> = broken
895 .iter()
896 .map(|(link, target)| {
897 format!(
898 "Broken symlink: {} → {} (target does not exist). \
899 Run `ito init` to repair.",
900 link.display(),
901 target.display()
902 )
903 })
904 .collect();
905 Some(lines.join("\n"))
906 }
907 CoordinationHealthStatus::WrongTargets { mismatched } => {
908 let lines: Vec<String> = mismatched
909 .iter()
910 .map(|(link, actual, expected)| {
911 format!(
912 "{} points to {} but should point to {}. \
913 Run `ito init` to repair.",
914 link.display(),
915 actual.display(),
916 expected.display()
917 )
918 })
919 .collect();
920 Some(lines.join("\n"))
921 }
922 CoordinationHealthStatus::NotWired { dirs } => {
923 let lines: Vec<String> = dirs
924 .iter()
925 .map(|dir| {
926 format!(
927 "{} is a regular directory, not a symlink to the coordination worktree. \
928 Run `ito init` to wire symlinks.",
929 dir.display()
930 )
931 })
932 .collect();
933 Some(lines.join("\n"))
934 }
935 }
936}
937
938#[cfg(test)]
939#[path = "coordination_tests.rs"]
940mod tests;