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/// Canonical `.gitignore` entries for the coordination-worktree symlinks.
23///
24/// Each entry corresponds to one directory in [`COORDINATION_DIRS`]
25/// prefixed with `.ito/`. Both [`update_gitignore_for_symlinks`] and the
26/// `coordination/gitignore-entries` rule consume this list, so any changes
27/// stay in lockstep.
28const COORDINATION_GITIGNORE_ENTRIES: &[&str] = &[
29    ".ito/changes",
30    ".ito/specs",
31    ".ito/modules",
32    ".ito/workflows",
33    ".ito/audit",
34];
35
36/// Return the canonical `.gitignore` entries for coordination-worktree
37/// symlinks.
38///
39/// The slice contents are guaranteed to match `.ito/<dir>` for each
40/// `<dir>` in [`COORDINATION_DIRS`] (verified by a unit test in this
41/// module).
42#[must_use]
43pub fn gitignore_entries() -> &'static [&'static str] {
44    COORDINATION_GITIGNORE_ENTRIES
45}
46
47// ── Platform-abstracted symlink creation ─────────────────────────────────────
48
49/// Create a directory symlink `dst` → `src` in a platform-appropriate way.
50///
51/// On Unix this calls [`std::os::unix::fs::symlink`].  On Windows this creates
52/// an NTFS junction so standard users do not need Developer Mode or elevated
53/// privileges. The function returns an
54/// [`io::Error`] if the underlying OS call fails.
55///
56/// # Errors
57///
58/// Returns an error when the OS rejects the symlink creation, for example
59/// because `dst` already exists or the caller lacks the required privileges.
60pub 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
79// ── Symlink target resolution ─────────────────────────────────────────────────
80
81/// Return the resolved symlink target for `path`, or `None` if `path` is not a
82/// symlink (including when `path` does not exist).
83fn read_link_opt(path: &Path) -> io::Result<Option<PathBuf>> {
84    match read_dir_link(path) {
85        Ok(target) => Ok(Some(target)),
86        // Path does not exist — not a symlink.
87        Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None),
88        // Path exists but is not a symlink (EINVAL on Unix).
89        Err(e) if e.kind() == io::ErrorKind::InvalidInput => Ok(None),
90        // Some platforms return `Other` for non-symlink paths.
91        Err(_) if path.exists() => Ok(None),
92        Err(e) => Err(e),
93    }
94}
95
96// ── wire_coordination_symlinks ────────────────────────────────────────────────
97
98/// Wire `.ito/<dir>` → `<worktree_ito_path>/<dir>` for every coordination
99/// directory.
100///
101/// For each directory in [`COORDINATION_DIRS`] the function applies the
102/// following logic:
103///
104/// 1. **Already a correct symlink** — ensure the target exists, then skip.
105/// 2. **Wrong symlink** — fail with explicit actual/expected target guidance.
106/// 3. **Real directory that is empty** — remove it and create the symlink.
107/// 4. **Real directory with content** — fail so duplicate state is not merged
108///    implicitly.
109/// 5. **Does not exist** — create the symlink directly.
110///
111/// # Errors
112///
113/// Returns [`CoreError::Io`] when any filesystem operation fails, with a
114/// message that includes the affected path and a suggested remediation.
115pub 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        // Check whether `src` is already a symlink pointing at `dst`.
121        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            // Resolve both paths for comparison so relative vs absolute doesn't
133            // cause spurious mismatches.
134            let resolved_target = if target.is_absolute() {
135                lexical_normalize(&target)
136            } else {
137                // Symlink targets are relative to the directory containing the
138                // link, i.e. `ito_path`.
139                lexical_normalize(&ito_path.join(&target))
140            };
141            let resolved_dst = lexical_normalize(&dst);
142
143            if resolved_target == resolved_dst || target == dst {
144                // Already wired correctly. Recreate the target directory if it
145                // was removed, repairing a broken-but-correct link.
146                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        // `src` does not exist at this point — create the symlink.
193
194        // Ensure the target directory exists in the worktree.
195        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
238/// Recursively copy the directory tree rooted at `src` into `dst`.
239///
240/// `dst` is created if it does not already exist.  Files are copied with
241/// [`fs::copy`], which preserves content but not all metadata (e.g. ownership
242/// and extended attributes are not transferred).
243///
244/// # Errors
245///
246/// Returns [`CoreError::Io`] if any directory cannot be created, any file
247/// cannot be read or written, or a directory entry cannot be inspected.
248fn 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
353// ── update_gitignore_for_symlinks ─────────────────────────────────────────────
354
355/// Append coordination-symlink entries to `<project_root>/.gitignore`.
356///
357/// The following block is added when it is not already present:
358///
359/// ```text
360/// # Ito coordination worktree symlinks
361/// .ito/changes
362/// .ito/specs
363/// .ito/modules
364/// .ito/workflows
365/// .ito/audit
366/// ```
367///
368/// Entries that already appear anywhere in the file are not duplicated.
369///
370/// # Errors
371///
372/// Returns [`CoreError::Io`] when the `.gitignore` file cannot be read or
373/// written, with the file path included in the message.
374pub 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    // Collect canonical lines that are genuinely missing.
392    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    // Ensure there is a trailing newline before appending.
405    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
428// ── remove_coordination_symlinks ──────────────────────────────────────────────
429
430/// Tear down coordination symlinks and restore real directories.
431///
432/// For each entry in [`COORDINATION_DIRS`], if `<ito_path>/<dir>` is a symlink
433/// the function:
434///
435/// 1. Reads the content from the symlink target (`<worktree_ito_path>/<dir>`).
436/// 2. Removes the symlink.
437/// 3. Creates a real directory at `<ito_path>/<dir>`.
438/// 4. Moves all content from the worktree directory back.
439///
440/// Entries that are already real directories (or do not exist) are left
441/// untouched.
442///
443/// # Errors
444///
445/// Returns [`CoreError::Io`] when any filesystem operation fails, with a
446/// message that includes the affected path and a suggested remediation.
447pub 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            // Not a symlink — nothing to restore.
464            continue;
465        };
466
467        // Remove the symlink.
468        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        // Create the real directory.
479        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        // Move content back from the worktree directory if it exists.
491        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// ── check_coordination_health ─────────────────────────────────────────────────
523
524/// Health status of the coordination worktree setup.
525///
526/// Returned by [`check_coordination_health`] to describe whether the
527/// coordination symlinks and worktree directory are in a usable state.
528#[derive(Debug, PartialEq)]
529pub enum CoordinationHealthStatus {
530    /// Everything is fine — worktree directory exists and all symlinks resolve.
531    Healthy,
532    /// Storage mode is `Embedded` — no worktree or symlinks are expected.
533    Embedded,
534    /// The coordination worktree directory is missing.
535    WorktreeMissing {
536        /// The path where the worktree was expected.
537        expected_path: PathBuf,
538    },
539    /// One or more `.ito/<dir>` symlinks point at targets that do not exist.
540    BrokenSymlinks {
541        /// Each entry is `(link_path, target_path)` for a broken symlink.
542        broken: Vec<(PathBuf, PathBuf)>,
543    },
544    /// One or more `.ito/<dir>` symlinks resolve, but point at the wrong target.
545    WrongTargets {
546        /// Each entry is `(link_path, actual_target, expected_target)`.
547        mismatched: Vec<(PathBuf, PathBuf, PathBuf)>,
548    },
549    /// One or more `.ito/<dir>` entries are real directories instead of symlinks.
550    NotWired {
551        /// Paths of the real directories that should be symlinks.
552        dirs: Vec<PathBuf>,
553    },
554}
555
556/// Inspect the coordination worktree and symlink state, returning a
557/// [`CoordinationHealthStatus`] that describes any problem found.
558///
559/// # Check logic
560///
561/// 1. If `storage` is [`CoordinationStorage::Embedded`], return
562///    [`CoordinationHealthStatus::Embedded`] immediately — no worktree is
563///    expected in this mode.
564/// 2. If the worktree directory at `worktree_ito_path` does not exist, return
565///    [`CoordinationHealthStatus::WorktreeMissing`].
566/// 3. For each of the five coordination directories (`changes`, `specs`,
567///    `modules`, `workflows`, `audit`):
568///    - If `<ito_path>/<dir>` is a symlink whose target does not exist, record
569///      it as broken.
570///    - If `<ito_path>/<dir>` is a symlink whose resolved target exists but is
571///      not the expected `<worktree_ito_path>/<dir>`, record it as mismatched.
572///    - If `<ito_path>/<dir>` is a real directory (not a symlink), record it as
573///      not-wired.
574/// 4. Return [`CoordinationHealthStatus::BrokenSymlinks`],
575///    [`CoordinationHealthStatus::WrongTargets`], or
576///    [`CoordinationHealthStatus::NotWired`] when problems are found, or
577///    [`CoordinationHealthStatus::Healthy`] when everything looks good.
578///
579/// Note: broken symlinks take precedence over mismatched targets, which take
580/// precedence over not-wired directories in the return value.
581pub 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                // It is a symlink — check whether the target resolves.
612                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                // Path exists but is not a symlink (EINVAL on Unix).
625                if link_path.exists() {
626                    not_wired.push(link_path);
627                }
628            }
629            Err(_) => {
630                // On some platforms a non-symlink path returns a different
631                // error kind.  Treat an existing path as not-wired.
632                if link_path.exists() {
633                    not_wired.push(link_path);
634                }
635                // If the path does not exist at all, it is neither broken nor
636                // not-wired — skip it.
637            }
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
808/// Return an actionable error message for an unhealthy coordination state, or
809/// `None` when the status is [`CoordinationHealthStatus::Healthy`] or
810/// [`CoordinationHealthStatus::Embedded`].
811///
812/// Messages follow the **What / Why / How** pattern so that both humans and
813/// AI agents can act on them immediately.
814pub 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;