Skip to main content

ito_core/
coordination.rs

1//! Symlink wiring for coordination worktrees.
2//!
3//! When Ito operates in a coordination-worktree layout, the canonical `.ito/`
4//! subdirectories (`changes`, `specs`, `modules`, `workflows`, `audit`) are
5//! replaced by symlinks that point into a shared coordination worktree.  This
6//! module provides the helpers to create, verify, and tear down those symlinks,
7//! as well as health-check utilities for detecting missing or broken setups.
8
9use 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
19/// Subdirectories of `.ito/` that are wired to the coordination worktree.
20pub const COORDINATION_DIRS: &[&str] = &["changes", "specs", "modules", "workflows", "audit"];
21
22// ── Platform-abstracted symlink creation ─────────────────────────────────────
23
24/// Create a directory symlink `dst` → `src` in a platform-appropriate way.
25///
26/// On Unix this calls [`std::os::unix::fs::symlink`].  On Windows this creates
27/// an NTFS junction so standard users do not need Developer Mode or elevated
28/// privileges. The function returns an
29/// [`io::Error`] if the underlying OS call fails.
30///
31/// # Errors
32///
33/// Returns an error when the OS rejects the symlink creation, for example
34/// because `dst` already exists or the caller lacks the required privileges.
35pub 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
54// ── Symlink target resolution ─────────────────────────────────────────────────
55
56/// Return the resolved symlink target for `path`, or `None` if `path` is not a
57/// symlink (including when `path` does not exist).
58fn read_link_opt(path: &Path) -> io::Result<Option<PathBuf>> {
59    match read_dir_link(path) {
60        Ok(target) => Ok(Some(target)),
61        // Path does not exist — not a symlink.
62        Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None),
63        // Path exists but is not a symlink (EINVAL on Unix).
64        Err(e) if e.kind() == io::ErrorKind::InvalidInput => Ok(None),
65        // Some platforms return `Other` for non-symlink paths.
66        Err(_) if path.exists() => Ok(None),
67        Err(e) => Err(e),
68    }
69}
70
71// ── wire_coordination_symlinks ────────────────────────────────────────────────
72
73/// Wire `.ito/<dir>` → `<worktree_ito_path>/<dir>` for every coordination
74/// directory.
75///
76/// For each directory in [`COORDINATION_DIRS`] the function applies the
77/// following logic:
78///
79/// 1. **Already a correct symlink** — skip (idempotent).
80/// 2. **Real directory with content** — create the target directory in the
81///    worktree, move all entries across, remove the now-empty source directory,
82///    then create the symlink.
83/// 3. **Real directory that is empty** — remove it and create the symlink.
84/// 4. **Does not exist** — create the symlink directly.
85///
86/// # Errors
87///
88/// Returns [`CoreError::Io`] when any filesystem operation fails, with a
89/// message that includes the affected path and a suggested remediation.
90pub 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        // Check whether `src` is already a symlink pointing at `dst`.
96        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            // Resolve both paths for comparison so relative vs absolute doesn't
108            // cause spurious mismatches.
109            let resolved_target = if target.is_absolute() {
110                lexical_normalize(&target)
111            } else {
112                // Symlink targets are relative to the directory containing the
113                // link, i.e. `ito_path`.
114                lexical_normalize(&ito_path.join(&target))
115            };
116            let resolved_dst = lexical_normalize(&dst);
117
118            if resolved_target == resolved_dst || target == dst {
119                // Already wired correctly — nothing to do.
120                continue;
121            }
122
123            // Symlink exists but points somewhere else — remove it so we can
124            // re-create it pointing at the right place.
125            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            // `src` is a real directory (possibly with content).
136            migrate_dir_to_worktree(&src, &dst)?;
137        }
138        // `src` does not exist at this point — create the symlink.
139
140        // Ensure the target directory exists in the worktree.
141        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
170/// Move all entries from `src_dir` into `dst_dir`, then remove `src_dir`.
171///
172/// `dst_dir` is created if it does not already exist.
173///
174/// # Cross-filesystem moves
175///
176/// `fs::rename` is attempted first.  When the source and destination reside on
177/// different filesystems the OS returns `EXDEV` ("Invalid cross-device link").
178/// In that case the function falls back to a recursive copy followed by
179/// deletion of the source, so migration works even when the XDG data directory
180/// (or any other configured path) is on a separate partition or mount point.
181fn 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        // Fall back to copy-then-delete when `rename` fails with EXDEV (cross-
222        // device link), which happens when `src_dir` and `dst_dir` are on
223        // different filesystems.
224        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
302/// Recursively copy the directory tree rooted at `src` into `dst`.
303///
304/// `dst` is created if it does not already exist.  Files are copied with
305/// [`fs::copy`], which preserves content but not all metadata (e.g. ownership
306/// and extended attributes are not transferred).
307///
308/// # Errors
309///
310/// Returns [`CoreError::Io`] if any directory cannot be created, any file
311/// cannot be read or written, or a directory entry cannot be inspected.
312fn 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
417// ── update_gitignore_for_symlinks ─────────────────────────────────────────────
418
419/// Append coordination-symlink entries to `<project_root>/.gitignore`.
420///
421/// The following block is added when it is not already present:
422///
423/// ```text
424/// # Ito coordination worktree symlinks
425/// .ito/changes
426/// .ito/specs
427/// .ito/modules
428/// .ito/workflows
429/// .ito/audit
430/// ```
431///
432/// Entries that already appear anywhere in the file are not duplicated.
433///
434/// # Errors
435///
436/// Returns [`CoreError::Io`] when the `.gitignore` file cannot be read or
437/// written, with the file path included in the message.
438pub 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    // Build the lines we want to ensure are present.
456    let desired_lines: Vec<String> = COORDINATION_DIRS
457        .iter()
458        .map(|dir| format!(".ito/{dir}"))
459        .collect();
460
461    // Collect lines that are genuinely missing.
462    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    // Ensure there is a trailing newline before appending.
475    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
498// ── remove_coordination_symlinks ──────────────────────────────────────────────
499
500/// Tear down coordination symlinks and restore real directories.
501///
502/// For each entry in [`COORDINATION_DIRS`], if `<ito_path>/<dir>` is a symlink
503/// the function:
504///
505/// 1. Reads the content from the symlink target (`<worktree_ito_path>/<dir>`).
506/// 2. Removes the symlink.
507/// 3. Creates a real directory at `<ito_path>/<dir>`.
508/// 4. Moves all content from the worktree directory back.
509///
510/// Entries that are already real directories (or do not exist) are left
511/// untouched.
512///
513/// # Errors
514///
515/// Returns [`CoreError::Io`] when any filesystem operation fails, with a
516/// message that includes the affected path and a suggested remediation.
517pub 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            // Not a symlink — nothing to restore.
534            continue;
535        };
536
537        // Remove the symlink.
538        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        // Create the real directory.
549        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        // Move content back from the worktree directory if it exists.
561        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// ── check_coordination_health ─────────────────────────────────────────────────
593
594/// Health status of the coordination worktree setup.
595///
596/// Returned by [`check_coordination_health`] to describe whether the
597/// coordination symlinks and worktree directory are in a usable state.
598#[derive(Debug, PartialEq)]
599pub enum CoordinationHealthStatus {
600    /// Everything is fine — worktree directory exists and all symlinks resolve.
601    Healthy,
602    /// Storage mode is `Embedded` — no worktree or symlinks are expected.
603    Embedded,
604    /// The coordination worktree directory is missing.
605    WorktreeMissing {
606        /// The path where the worktree was expected.
607        expected_path: PathBuf,
608    },
609    /// One or more `.ito/<dir>` symlinks point at targets that do not exist.
610    BrokenSymlinks {
611        /// Each entry is `(link_path, target_path)` for a broken symlink.
612        broken: Vec<(PathBuf, PathBuf)>,
613    },
614    /// One or more `.ito/<dir>` entries are real directories instead of symlinks.
615    NotWired {
616        /// Paths of the real directories that should be symlinks.
617        dirs: Vec<PathBuf>,
618    },
619}
620
621/// Inspect the coordination worktree and symlink state, returning a
622/// [`CoordinationHealthStatus`] that describes any problem found.
623///
624/// # Check logic
625///
626/// 1. If `storage` is [`CoordinationStorage::Embedded`], return
627///    [`CoordinationHealthStatus::Embedded`] immediately — no worktree is
628///    expected in this mode.
629/// 2. If the worktree directory at `worktree_ito_path` does not exist, return
630///    [`CoordinationHealthStatus::WorktreeMissing`].
631/// 3. For each of the five coordination directories (`changes`, `specs`,
632///    `modules`, `workflows`, `audit`):
633///    - If `<ito_path>/<dir>` is a symlink whose target does not exist, record
634///      it as broken.
635///    - If `<ito_path>/<dir>` is a real directory (not a symlink), record it as
636///      not-wired.
637/// 4. Return [`CoordinationHealthStatus::BrokenSymlinks`] or
638///    [`CoordinationHealthStatus::NotWired`] when problems are found, or
639///    [`CoordinationHealthStatus::Healthy`] when everything looks good.
640///
641/// Note: broken symlinks take precedence over not-wired directories in the
642/// return value.  If both are present, `BrokenSymlinks` is returned.
643pub 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                // It is a symlink — check whether the target resolves.
672                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                // Path exists but is not a symlink (EINVAL on Unix).
683                if link_path.exists() {
684                    not_wired.push(link_path);
685                }
686            }
687            Err(_) => {
688                // On some platforms a non-symlink path returns a different
689                // error kind.  Treat an existing path as not-wired.
690                if link_path.exists() {
691                    not_wired.push(link_path);
692                }
693                // If the path does not exist at all, it is neither broken nor
694                // not-wired — skip it.
695            }
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
862/// Return an actionable error message for an unhealthy coordination state, or
863/// `None` when the status is [`CoordinationHealthStatus::Healthy`] or
864/// [`CoordinationHealthStatus::Embedded`].
865///
866/// Messages follow the **What / Why / How** pattern so that both humans and
867/// AI agents can act on them immediately.
868pub 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;