1use 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
17pub 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#[must_use]
33pub const fn managed_gitignore_entries() -> &'static [&'static str] {
34 MANAGED_GITIGNORE_ENTRIES
35}
36
37#[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
63#[serde(tag = "kind", rename_all = "snake_case")]
64pub enum LegacyCoordinationClass {
65 Absent,
67 Embedded,
69 Legacy,
71 Ambiguous,
73}
74
75#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
77pub struct CoordinationConfigEvidence {
78 pub enabled: bool,
80 pub storage: String,
82 pub branch: String,
84 pub worktree_path: Option<String>,
86}
87
88#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
90#[serde(tag = "kind", rename_all = "snake_case")]
91pub enum ManagedPathKind {
92 Missing,
94 Directory {
96 empty: bool,
98 },
99 Link {
101 target: PathBuf,
103 resolved_target: PathBuf,
105 matches_expected: Option<bool>,
107 target_exists: bool,
109 },
110 Other,
112}
113
114#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
116pub struct ManagedPathEvidence {
117 pub name: String,
119 pub path: PathBuf,
121 pub kind: ManagedPathKind,
123}
124
125#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
127pub struct CoordinationGitignoreEvidence {
128 pub marker_present: bool,
130 pub matching_entries: Vec<String>,
132}
133
134#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
136pub struct LegacyCoordinationReport {
137 pub classification: LegacyCoordinationClass,
139 pub config: CoordinationConfigEvidence,
141 pub managed_paths: Vec<ManagedPathEvidence>,
143 pub gitignore: CoordinationGitignoreEvidence,
145}
146
147pub 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;