Skip to main content

ito_core/
legacy_coordination.rs

1//! Side-effect-free inspection of legacy coordination-worktree state.
2
3use std::fs;
4use std::io;
5use std::path::{Path, PathBuf};
6
7use ito_config::ito_dir::lexical_normalize;
8use ito_config::types::{BackendApiConfig, CoordinationBranchConfig, CoordinationStorage};
9use serde::Serialize;
10
11use crate::errors::{CoreError, CoreResult};
12use crate::git_remote::resolve_org_repo_from_config_or_remote;
13use crate::repo_paths::coordination_worktree_path;
14
15const GITIGNORE_MARKER: &str = "# Ito coordination worktree symlinks";
16
17/// Ito state directories formerly projected through the coordination worktree.
18///
19/// This neutral definition stays available when coordination runtime code is
20/// excluded from a build, so detection and recovery do not depend on it.
21pub const MANAGED_STATE_DIRS: &[&str] = &["changes", "specs", "modules", "workflows", "audit"];
22
23const MANAGED_GITIGNORE_ENTRIES: &[&str] = &[
24    ".ito/changes",
25    ".ito/specs",
26    ".ito/modules",
27    ".ito/workflows",
28    ".ito/audit",
29];
30
31/// Canonical legacy `.gitignore` entries corresponding to [`MANAGED_STATE_DIRS`].
32#[must_use]
33pub const fn managed_gitignore_entries() -> &'static [&'static str] {
34    MANAGED_GITIGNORE_ENTRIES
35}
36
37/// Resolve the legacy coordination `.ito` root from explicit or local Git evidence.
38///
39/// This performs no network access and remains separate from coordination
40/// provisioning, synchronization, and symlink-runtime code.
41#[must_use]
42pub fn expected_coordination_ito_root(
43    project_root: &Path,
44    ito_root: &Path,
45    coordination: &CoordinationBranchConfig,
46    backend: &BackendApiConfig,
47) -> Option<PathBuf> {
48    if coordination
49        .worktree_path
50        .as_deref()
51        .is_some_and(|path| !path.trim().is_empty())
52    {
53        return Some(coordination_worktree_path(coordination, ito_root, "", "").join(".ito"));
54    }
55
56    resolve_org_repo_from_config_or_remote(project_root, backend).map(|(org, repo)| {
57        coordination_worktree_path(coordination, ito_root, &org, &repo).join(".ito")
58    })
59}
60
61/// Overall classification of coordination storage evidence.
62#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
63#[serde(tag = "kind", rename_all = "snake_case")]
64pub enum LegacyCoordinationClass {
65    /// No managed Ito state paths exist yet.
66    Absent,
67    /// Managed Ito state is stored in real repository directories.
68    Embedded,
69    /// Legacy coordination storage is configured or visibly wired.
70    Legacy,
71    /// Evidence conflicts and requires human reconciliation.
72    Ambiguous,
73}
74
75/// Resolved coordination configuration recorded by the detector.
76#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
77pub struct CoordinationConfigEvidence {
78    /// Whether coordination synchronization is enabled.
79    pub enabled: bool,
80    /// Configured storage identifier.
81    pub storage: String,
82    /// Configured coordination branch.
83    pub branch: String,
84    /// Configured worktree path override, if any.
85    pub worktree_path: Option<String>,
86}
87
88/// Filesystem kind observed at one managed Ito path.
89#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
90#[serde(tag = "kind", rename_all = "snake_case")]
91pub enum ManagedPathKind {
92    /// The managed path is absent.
93    Missing,
94    /// The managed path is a real directory.
95    Directory {
96        /// Whether the directory contains no entries.
97        empty: bool,
98    },
99    /// The managed path is a symbolic link or directory junction.
100    Link {
101        /// Target stored in the link itself.
102        target: PathBuf,
103        /// Link target lexically resolved against the Ito root when relative.
104        resolved_target: PathBuf,
105        /// Whether the resolved target matches the expected coordination target.
106        matches_expected: Option<bool>,
107        /// Whether the target currently exists.
108        target_exists: bool,
109    },
110    /// The path exists but is neither a directory nor a coordination link.
111    Other,
112}
113
114/// Evidence for one managed Ito state path.
115#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
116pub struct ManagedPathEvidence {
117    /// Managed directory name beneath the Ito root.
118    pub name: String,
119    /// Full path inspected by the detector.
120    pub path: PathBuf,
121    /// Filesystem kind observed without following links.
122    pub kind: ManagedPathKind,
123}
124
125/// Evidence found in the repository `.gitignore`.
126#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
127pub struct CoordinationGitignoreEvidence {
128    /// Whether the managed coordination marker or its complete entry set exists.
129    pub marker_present: bool,
130    /// Canonical coordination entries found after trimming surrounding whitespace.
131    pub matching_entries: Vec<String>,
132}
133
134/// Complete side-effect-free legacy coordination inspection report.
135#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
136pub struct LegacyCoordinationReport {
137    /// Overall storage classification.
138    pub classification: LegacyCoordinationClass,
139    /// Resolved configuration evidence.
140    pub config: CoordinationConfigEvidence,
141    /// Per-path filesystem evidence.
142    pub managed_paths: Vec<ManagedPathEvidence>,
143    /// Managed `.gitignore` evidence.
144    pub gitignore: CoordinationGitignoreEvidence,
145}
146
147/// Inspect coordination configuration and repository evidence without mutation.
148///
149/// `project_root` identifies the repository containing `.gitignore`, while
150/// `ito_root` identifies its resolved Ito directory. `config` must be the
151/// already-resolved coordination configuration. When
152/// `expected_coordination_ito_root` is present, link targets are compared with
153/// the expected shared `.ito` root; when it is absent, target matching remains
154/// unknown and wrong-target detection is intentionally unavailable.
155///
156/// # Errors
157///
158/// Returns an error when filesystem metadata or repository files cannot be read.
159pub fn inspect_legacy_coordination(
160    project_root: &Path,
161    ito_root: &Path,
162    config: &CoordinationBranchConfig,
163    expected_coordination_ito_root: Option<&Path>,
164) -> CoreResult<LegacyCoordinationReport> {
165    let managed_paths = MANAGED_STATE_DIRS
166        .iter()
167        .map(|name| inspect_managed_path(ito_root, name, expected_coordination_ito_root))
168        .collect::<CoreResult<Vec<_>>>()?;
169    let gitignore = inspect_gitignore(project_root)?;
170    let classification = classify(config, &managed_paths, &gitignore);
171
172    Ok(LegacyCoordinationReport {
173        classification,
174        config: CoordinationConfigEvidence {
175            enabled: config.enabled.0,
176            storage: config.storage.as_str().to_string(),
177            branch: config.name.clone(),
178            worktree_path: config.worktree_path.clone(),
179        },
180        managed_paths,
181        gitignore,
182    })
183}
184
185fn inspect_managed_path(
186    ito_root: &Path,
187    name: &str,
188    expected_coordination_ito_root: Option<&Path>,
189) -> CoreResult<ManagedPathEvidence> {
190    let path = ito_root.join(name);
191    let metadata = match fs::symlink_metadata(&path) {
192        Ok(metadata) => metadata,
193        Err(error) if error.kind() == io::ErrorKind::NotFound => {
194            return Ok(ManagedPathEvidence {
195                name: name.to_string(),
196                path,
197                kind: ManagedPathKind::Missing,
198            });
199        }
200        Err(error) => {
201            return Err(CoreError::io(
202                format!(
203                    "cannot inspect legacy coordination path '{}': check filesystem permissions",
204                    path.display()
205                ),
206                error,
207            ));
208        }
209    };
210
211    let link_target = match read_dir_link(&path) {
212        Ok(target) => Some(target),
213        Err(error) if metadata.file_type().is_symlink() => {
214            return Err(CoreError::io(
215                format!(
216                    "cannot read legacy coordination link '{}': check filesystem permissions",
217                    path.display()
218                ),
219                error,
220            ));
221        }
222        Err(_) => None,
223    };
224
225    let kind = if let Some(target) = link_target {
226        let resolved_target = if target.is_absolute() {
227            lexical_normalize(&target)
228        } else {
229            lexical_normalize(&ito_root.join(&target))
230        };
231        let matches_expected = expected_coordination_ito_root
232            .map(|expected_root| resolved_target == lexical_normalize(&expected_root.join(name)));
233        let target_exists = path_exists(&resolved_target)?;
234
235        ManagedPathKind::Link {
236            target,
237            resolved_target,
238            matches_expected,
239            target_exists,
240        }
241    } else if metadata.is_dir() {
242        let mut entries = fs::read_dir(&path).map_err(|error| {
243            CoreError::io(
244                format!(
245                    "cannot read legacy coordination directory '{}': check filesystem permissions",
246                    path.display()
247                ),
248                error,
249            )
250        })?;
251        let first_entry = entries.next().transpose().map_err(|error| {
252            CoreError::io(
253                format!(
254                    "cannot inspect entries in legacy coordination directory '{}': check filesystem permissions",
255                    path.display()
256                ),
257                error,
258            )
259        })?;
260        ManagedPathKind::Directory {
261            empty: first_entry.is_none(),
262        }
263    } else {
264        ManagedPathKind::Other
265    };
266
267    Ok(ManagedPathEvidence {
268        name: name.to_string(),
269        path,
270        kind,
271    })
272}
273
274fn path_exists(path: &Path) -> CoreResult<bool> {
275    match fs::metadata(path) {
276        Ok(_) => Ok(true),
277        Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(false),
278        Err(error) => Err(CoreError::io(
279            format!(
280                "cannot inspect legacy coordination link target '{}': check filesystem permissions",
281                path.display()
282            ),
283            error,
284        )),
285    }
286}
287
288fn inspect_gitignore(project_root: &Path) -> CoreResult<CoordinationGitignoreEvidence> {
289    let path = project_root.join(".gitignore");
290    let contents = match fs::read_to_string(&path) {
291        Ok(contents) => contents,
292        Err(error) if error.kind() == io::ErrorKind::NotFound => String::new(),
293        Err(error) => {
294            return Err(CoreError::io(
295                format!(
296                    "cannot inspect legacy coordination marker in '{}': check filesystem permissions",
297                    path.display()
298                ),
299                error,
300            ));
301        }
302    };
303
304    let lines = contents.lines().map(str::trim).collect::<Vec<_>>();
305    let matching_entries = managed_gitignore_entries()
306        .iter()
307        .filter(|entry| lines.iter().any(|line| line == *entry))
308        .map(|entry| (*entry).to_string())
309        .collect::<Vec<_>>();
310    let marker_present = lines.contains(&GITIGNORE_MARKER)
311        || matching_entries.len() == managed_gitignore_entries().len();
312
313    Ok(CoordinationGitignoreEvidence {
314        marker_present,
315        matching_entries,
316    })
317}
318
319fn classify(
320    config: &CoordinationBranchConfig,
321    managed_paths: &[ManagedPathEvidence],
322    gitignore: &CoordinationGitignoreEvidence,
323) -> LegacyCoordinationClass {
324    let configured_legacy = config.enabled.0 && config.storage == CoordinationStorage::Worktree;
325    let configured_main = !config.enabled.0 || config.storage == CoordinationStorage::Embedded;
326    let has_link = managed_paths
327        .iter()
328        .any(|evidence| matches!(evidence.kind, ManagedPathKind::Link { .. }));
329    let has_wrong_link = managed_paths.iter().any(|evidence| {
330        matches!(
331            evidence.kind,
332            ManagedPathKind::Link {
333                matches_expected: Some(false),
334                ..
335            }
336        )
337    });
338    let link_roots = managed_paths
339        .iter()
340        .filter_map(|evidence| match &evidence.kind {
341            ManagedPathKind::Link {
342                resolved_target, ..
343            } => resolved_target.parent().map(lexical_normalize),
344            _ => None,
345        })
346        .collect::<std::collections::BTreeSet<_>>();
347    let has_inconsistent_link_roots = link_roots.len() > 1;
348    let has_authority_link = managed_paths.iter().any(|evidence| {
349        matches!(evidence.name.as_str(), "changes" | "specs")
350            && matches!(evidence.kind, ManagedPathKind::Link { .. })
351    });
352    let has_non_empty_authority_directory = managed_paths.iter().any(|evidence| {
353        matches!(evidence.name.as_str(), "changes" | "specs")
354            && matches!(evidence.kind, ManagedPathKind::Directory { empty: false })
355    });
356    let has_non_empty_runtime_directory = managed_paths.iter().any(|evidence| {
357        matches!(evidence.name.as_str(), "modules" | "workflows" | "audit")
358            && matches!(evidence.kind, ManagedPathKind::Directory { empty: false })
359    });
360    let has_real_directory = managed_paths
361        .iter()
362        .any(|evidence| matches!(evidence.kind, ManagedPathKind::Directory { .. }));
363    let has_other = managed_paths
364        .iter()
365        .any(|evidence| matches!(evidence.kind, ManagedPathKind::Other));
366    let all_missing = managed_paths
367        .iter()
368        .all(|evidence| matches!(evidence.kind, ManagedPathKind::Missing));
369    let has_gitignore_entries = !gitignore.matching_entries.is_empty();
370    let partial_gitignore_marker = has_gitignore_entries && !gitignore.marker_present;
371    let standalone_gitignore_marker =
372        gitignore.marker_present && gitignore.matching_entries.is_empty();
373    let worktree_config_with_materialized_paths =
374        configured_legacy && !has_link && has_real_directory;
375
376    let conflicting_evidence = has_wrong_link
377        || has_inconsistent_link_roots
378        || has_other
379        || partial_gitignore_marker
380        || standalone_gitignore_marker
381        || worktree_config_with_materialized_paths
382        || (has_authority_link && has_non_empty_authority_directory)
383        || (has_link && has_non_empty_runtime_directory)
384        || (configured_main && (has_link || has_gitignore_entries))
385        || (gitignore.marker_present && !has_link && has_real_directory);
386
387    if conflicting_evidence {
388        LegacyCoordinationClass::Ambiguous
389    } else if configured_legacy || has_link || has_gitignore_entries {
390        LegacyCoordinationClass::Legacy
391    } else if all_missing {
392        LegacyCoordinationClass::Absent
393    } else if has_real_directory {
394        LegacyCoordinationClass::Embedded
395    } else {
396        LegacyCoordinationClass::Ambiguous
397    }
398}
399
400fn read_dir_link(path: &Path) -> io::Result<PathBuf> {
401    #[cfg(windows)]
402    {
403        junction::get_target(path)
404    }
405
406    #[cfg(not(windows))]
407    {
408        fs::read_link(path)
409    }
410}
411
412#[cfg(test)]
413#[path = "legacy_coordination_tests.rs"]
414mod legacy_coordination_tests;