sqry_core/workspace/discovery.rs
1//! Workspace repository discovery utilities.
2//!
3//! Discovery scans a workspace root for repositories that have been indexed
4//! by `sqry index`. The canonical marker is `.sqry/graph/manifest.json` —
5//! the same file that `build_unified_graph_inner` writes (see
6//! `graph/unified/persistence/mod.rs`'s `GRAPH_DIR_NAME` and
7//! `MANIFEST_FILE_NAME` constants). The earlier `.sqry-index` placeholder
8//! was never written by the live build pipeline and is removed outright;
9//! there is no legacy fallback (RR-10 Gap #2 retains the per-workspace
10//! repository cap to bound walker work regardless of marker name).
11//!
12//! The walker honours `.gitignore` rules and additionally skips a small
13//! set of dependency / build directories whose contents must never be
14//! treated as discoverable repositories even when those directories are
15//! present without a `.gitignore` (e.g. `node_modules`, `target`). The
16//! ignore list is the repo-wide
17//! [`crate::project::path_utils::DEFAULT_IGNORED_DIRS`] (consulted via
18//! [`crate::project::path_utils::is_ignored_dir`]) so workspace discovery
19//! and single-repo project detection share one source of truth.
20
21use std::fs;
22use std::path::{Path, PathBuf};
23
24use ignore::WalkBuilder;
25use thiserror::Error;
26
27use super::error::{WorkspaceError, WorkspaceResult};
28use super::registry::{WorkspaceRepoId, WorkspaceRepository};
29// RR-10 Gap #2: Import repository count limit for DoS prevention
30use crate::config::buffers::max_repositories;
31// Repo-wide source of truth for directories to skip during repo discovery.
32use crate::project::path_utils::is_ignored_dir;
33
34/// Canonical marker filename written by `sqry index` under
35/// `<repo>/.sqry/graph/`. Discovery treats any file whose name matches this
36/// constant and whose parent directory is `.sqry/graph` as evidence of a
37/// repository root one level above.
38const MANIFEST_FILE_NAME: &str = "manifest.json";
39
40/// Directory segment containing [`MANIFEST_FILE_NAME`]. Used to validate
41/// that a candidate `manifest.json` actually lives inside a sqry graph
42/// directory (and not, say, an unrelated NPM `manifest.json`).
43const GRAPH_DIR_SEGMENT: &str = "graph";
44
45/// Parent of [`GRAPH_DIR_SEGMENT`]. The full canonical relative path is
46/// `.sqry/graph/manifest.json`.
47const SQRY_DIR_SEGMENT: &str = ".sqry";
48
49/// Discovery strategy for locating repositories within a workspace root.
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub enum DiscoveryMode {
52 /// Locate repositories by finding `.sqry/graph/manifest.json` markers
53 /// anywhere under root.
54 IndexFiles,
55 /// Only include repositories that are git roots (contain `.git/`) with
56 /// an index marker.
57 GitRoots,
58}
59
60/// Discover repositories beneath `root` according to `mode`.
61///
62/// # Errors
63///
64/// Returns [`WorkspaceError`] when filesystem traversal fails.
65pub fn discover_repositories(
66 root: &Path,
67 mode: DiscoveryMode,
68) -> WorkspaceResult<Vec<WorkspaceRepository>> {
69 let mut repositories = Vec::new();
70
71 let walker = WalkBuilder::new(root)
72 .hidden(false)
73 .ignore(false)
74 .git_ignore(true)
75 .git_exclude(true)
76 .parents(true)
77 .filter_entry(|entry| {
78 // Skip well-known dependency / build directories so discovery
79 // never wastes work descending into them. The ignore list is
80 // owned by `crate::project::path_utils::DEFAULT_IGNORED_DIRS`
81 // and consulted through `is_ignored_dir` to keep workspace
82 // discovery and single-repo project detection in lockstep.
83 !is_ignored_dir(entry.file_name())
84 })
85 .build();
86
87 for result in walker {
88 let entry = match result {
89 Ok(ok) => ok,
90 Err(err) => {
91 let message = err.to_string();
92 let io_err = err
93 .into_io_error()
94 .unwrap_or_else(|| std::io::Error::other(message));
95 return Err(WorkspaceError::Discovery {
96 root: root.to_path_buf(),
97 source: io_err,
98 });
99 }
100 };
101
102 if entry.file_type().is_some_and(|ft| ft.is_dir()) {
103 continue;
104 }
105
106 if entry.file_name() != MANIFEST_FILE_NAME {
107 continue;
108 }
109
110 let manifest_path = entry.into_path();
111
112 // Validate the candidate sits under `<repo>/.sqry/graph/manifest.json`.
113 // Without this guard, any `manifest.json` (e.g. NPM's package
114 // manifest) would be misclassified as a sqry index marker.
115 let Some(graph_dir) = manifest_path.parent() else {
116 continue;
117 };
118 if graph_dir.file_name().and_then(|s| s.to_str()) != Some(GRAPH_DIR_SEGMENT) {
119 continue;
120 }
121 let Some(sqry_dir) = graph_dir.parent() else {
122 continue;
123 };
124 if sqry_dir.file_name().and_then(|s| s.to_str()) != Some(SQRY_DIR_SEGMENT) {
125 continue;
126 }
127 let Some(repo_root) = sqry_dir.parent().map(Path::to_path_buf) else {
128 continue;
129 };
130
131 if matches!(mode, DiscoveryMode::GitRoots) && !repo_root.join(".git").is_dir() {
132 continue;
133 }
134
135 let relative_path = repo_root.strip_prefix(root).unwrap_or(repo_root.as_path());
136 let repo_id = WorkspaceRepoId::new(relative_path);
137 let name = repo_root.file_name().map_or_else(
138 || repo_id.as_str().to_string(),
139 |os| os.to_string_lossy().into_owned(),
140 );
141
142 let metadata = fs::metadata(&manifest_path);
143 let last_indexed_at = metadata.ok().and_then(|meta| meta.modified().ok());
144
145 // RR-10 Gap #2: Enforce repository count limit to prevent DoS via
146 // workspaces containing thousands of indexed repositories.
147 let max_repos = max_repositories();
148 if repositories.len() >= max_repos {
149 return Err(WorkspaceError::TooManyRepositories {
150 found: repositories.len(),
151 limit: max_repos,
152 });
153 }
154
155 // Read the cheap manifest.json sidecar (no full graph load) so the
156 // registration-time snapshot is populated instead of the `None`
157 // `WorkspaceRepository::new` defaults to. `sqry workspace stats`
158 // re-reads this same manifest live at stats-computation time
159 // (see `DetailedWorkspaceStats::from_registry`), so this value is
160 // only a last-known fallback for other consumers, not the source
161 // of truth for `stats`. Must run before `repo_root` moves into
162 // `WorkspaceRepository::new`.
163 let symbol_count = WorkspaceRepository::symbol_count_from_manifest(&repo_root);
164
165 let mut repo =
166 WorkspaceRepository::new(repo_id, name, repo_root, manifest_path, last_indexed_at);
167 repo.symbol_count_at_registration = symbol_count;
168 repositories.push(repo);
169 }
170
171 repositories.sort_by(|a, b| a.id.cmp(&b.id));
172 repositories.dedup_by(|a, b| a.id == b.id);
173 Ok(repositories)
174}
175
176// ───────────────────────────────────────────────────────────────────────
177// Ancestor-walk discovery (sqry-mcp flakiness P1, cluster E foundation)
178// ───────────────────────────────────────────────────────────────────────
179//
180// The ancestor-walk discovery below is a **separate concern** from
181// `discover_repositories` above. Where `discover_repositories` walks
182// **down** a workspace root looking for indexed repositories, the
183// ancestor walker walks **up** from a starting path to locate the
184// project boundary that should anchor `.sqry/graph` lookups.
185//
186// Source: `E_p1_cluster.md` §E.1 + `E_p1_cluster.md` §Hand-offs +
187// `00_contracts.md` §3.CC-4. The ancestor walk fixes #237 (workspace
188// discovery and plugin-selection recovery) and gates #239 (workspace
189// artifact hygiene + nested-index creation guard).
190
191/// Maximum ancestor walk depth for [`discover_workspace_root`]. Mirrors
192/// the `MAX_ANCESTOR_DEPTH = 64` already used by
193/// `sqry-core/src/graph/acquisition.rs::find_workspace_root` and
194/// `sqry-cli/src/index_discovery.rs::find_nearest_index`. Bound is
195/// O(64) lstat calls per discovery — cheap enough that no caching is
196/// required, and small enough that no realistic project layout
197/// approaches the limit.
198pub const MAX_ANCESTOR_DEPTH: usize = 64;
199
200/// Project-boundary marker filenames consulted by
201/// [`discover_workspace_root`]. The walker stops at the **first**
202/// ancestor containing any of these markers, even if no
203/// `.sqry/graph` exists in or above that ancestor. Order does not
204/// matter — presence of any marker terminates the walk.
205///
206/// The five chosen markers cover ~95% of sqry's known user base by
207/// language. Expansion to `setup.py`, `Gemfile`, `mix.exs`,
208/// `build.gradle`, `pom.xml`, or `composer.json` is deliberately
209/// deferred until field reports show false-`None` discoveries in
210/// Ruby / Elixir / JVM monorepos (see `E_p1_cluster.md` Open Q4).
211pub const PROJECT_MARKERS: &[&str] = &[
212 ".git",
213 "Cargo.toml",
214 "package.json",
215 "pyproject.toml",
216 "go.mod",
217];
218
219/// Outcome of [`discover_workspace_root`]. Distinguishes "found a
220/// graph inside the project boundary" from "hit the project boundary
221/// with no graph above it" so callers that *create* indexes (e.g.
222/// `sqry index`) can refuse to descend into ancestors of an outer
223/// project.
224#[derive(Debug, Clone, PartialEq, Eq)]
225pub enum WorkspaceRootDiscovery {
226 /// `.sqry/graph` (or legacy `.sqry-index`) found at `root` AT OR
227 /// INSIDE the project boundary at `boundary`. `depth` is the
228 /// ancestor distance from the discovery starting point to `root`.
229 GraphFound {
230 /// The directory immediately above `.sqry/graph`. Use this as
231 /// the workspace root for graph-load operations.
232 root: PathBuf,
233 /// The first ancestor containing a [`PROJECT_MARKERS`] entry.
234 /// Equal to `root` when the project marker lives at the same
235 /// directory as the `.sqry/graph` artifact.
236 boundary: PathBuf,
237 /// Depth from the original starting point.
238 depth: usize,
239 /// `true` when the walk started at a regular file (we walk up
240 /// from its parent); `false` when it started at a directory.
241 is_file_scope: bool,
242 },
243 /// Project boundary reached without seeing any `.sqry/graph`.
244 /// Used by callers that want to *create* an index here.
245 BoundaryOnly {
246 /// The first ancestor containing a [`PROJECT_MARKERS`] entry.
247 boundary: PathBuf,
248 /// `true` when the walk started at a regular file (we walk up
249 /// from its parent); `false` when it started at a directory.
250 is_file_scope: bool,
251 },
252 /// Walked the full [`MAX_ANCESTOR_DEPTH`] (or hit the filesystem
253 /// root) without finding either a graph or a project marker.
254 /// Caller must require an explicit `--workspace-root`.
255 None,
256}
257
258/// Walk `start`'s ancestors looking for the canonical project root
259/// for sqry's purposes (per `E_p1_cluster.md` §E.1).
260///
261/// The walker:
262///
263/// 1. Best-effort canonicalises `start` (falls back to `start` on
264/// error).
265/// 2. If `start` is a file, begins from the parent directory.
266/// 3. Walks up to [`MAX_ANCESTOR_DEPTH`] ancestors, recording the
267/// *closest* `.sqry/graph` directory **and** the *closest*
268/// [`PROJECT_MARKERS`] hit. The walk terminates as soon as a
269/// project marker is observed — that marker is the
270/// boundary-of-record even if a graph also exists at a deeper
271/// level.
272/// 4. Maps the `(graph_found, boundary)` pair to the
273/// [`WorkspaceRootDiscovery`] enum:
274/// - graph inside boundary → [`WorkspaceRootDiscovery::GraphFound`].
275/// - graph above boundary (e.g. stray `~/.sqry/graph` while a
276/// `Cargo.toml` lives at `~/work/proj`) →
277/// [`WorkspaceRootDiscovery::BoundaryOnly`] (the outer graph is
278/// discarded).
279/// - graph but no marker → [`WorkspaceRootDiscovery::GraphFound`]
280/// (legacy bare-directory layouts).
281/// - no graph, marker only → [`WorkspaceRootDiscovery::BoundaryOnly`].
282/// - neither → [`WorkspaceRootDiscovery::None`].
283#[must_use]
284pub fn discover_workspace_root(start: &Path) -> WorkspaceRootDiscovery {
285 let canonical = start.canonicalize().unwrap_or_else(|_| start.to_path_buf());
286 let is_file_scope = canonical.is_file();
287 let mut current: PathBuf = if is_file_scope {
288 canonical
289 .parent()
290 .map_or_else(|| canonical.clone(), Path::to_path_buf)
291 } else {
292 canonical
293 };
294
295 let mut graph_found: Option<(PathBuf, usize)> = None;
296 let mut boundary: Option<PathBuf> = None;
297
298 for depth in 0..MAX_ANCESTOR_DEPTH {
299 // (a) Has the *current* directory been indexed?
300 let graph_dir = current.join(".sqry").join("graph");
301 let legacy_index = current.join(".sqry-index");
302 if graph_found.is_none() && (graph_dir.is_dir() || legacy_index.exists()) {
303 graph_found = Some((current.clone(), depth));
304 }
305
306 // (b) Is the *current* directory a project boundary?
307 if boundary.is_none() && PROJECT_MARKERS.iter().any(|m| current.join(m).exists()) {
308 boundary = Some(current.clone());
309 // Project marker terminates the walk — even if no graph
310 // was found, the project root is the boundary-of-record.
311 break;
312 }
313
314 // (c) Walk up.
315 if !current.pop() {
316 break;
317 }
318 }
319
320 match (graph_found, boundary) {
321 (Some((root, depth)), Some(boundary_path)) => {
322 if root.starts_with(&boundary_path) {
323 WorkspaceRootDiscovery::GraphFound {
324 root,
325 boundary: boundary_path,
326 depth,
327 is_file_scope,
328 }
329 } else {
330 // The graph is in an *outer* project (e.g. stray
331 // `~/.sqry/graph` above `~/work/proj/Cargo.toml`).
332 // Discard it — return BoundaryOnly so callers can
333 // build a fresh index inside the project.
334 WorkspaceRootDiscovery::BoundaryOnly {
335 boundary: boundary_path,
336 is_file_scope,
337 }
338 }
339 }
340 (Some((root, depth)), None) => {
341 // Bare-directory legacy layout: no project marker, but a
342 // graph was found. Treat the graph itself as the boundary
343 // to preserve backward compatibility for marker-less
344 // workspaces.
345 WorkspaceRootDiscovery::GraphFound {
346 boundary: root.clone(),
347 root,
348 depth,
349 is_file_scope,
350 }
351 }
352 (None, Some(boundary_path)) => WorkspaceRootDiscovery::BoundaryOnly {
353 boundary: boundary_path,
354 is_file_scope,
355 },
356 (None, None) => WorkspaceRootDiscovery::None,
357 }
358}
359
360/// Error returned by [`assert_no_ancestor_graph`] when a nested
361/// `.sqry/` would be created inside an outer project that already has
362/// one. Rendered with the full recovery template (per
363/// `E_p1_cluster.md` §E.3) so the user sees the offending path, the
364/// ancestor's graph location, and the project boundary together.
365#[derive(Debug, Clone, Error)]
366pub enum NestedIndexError {
367 #[error(
368 "refusing to create a nested .sqry/ index.\n\
369 An ancestor index already exists at: {ancestor_graph}\n\
370 Requested location: {requested}\n\
371 Project boundary detected at: {boundary}\n\
372 \n\
373 If this is intentional (e.g. a sub-project with its own graph), \
374 re-run with --allow-nested.\n\
375 Otherwise: cd to the project root ({boundary}) and run \
376 `sqry update` (incremental) or `sqry index --force` (rebuild).",
377 ancestor_graph = ancestor_graph.display(),
378 requested = requested.display(),
379 boundary = boundary.display(),
380 )]
381 /// Nested-index pollution: a `.sqry/graph` already exists at an
382 /// ancestor of `requested`, and they share the same project
383 /// boundary. The recovery message identifies all three paths.
384 AncestorExists {
385 /// The path the caller asked to index (canonicalised).
386 requested: PathBuf,
387 /// The `.sqry/graph` directory belonging to the outer project.
388 ancestor_graph: PathBuf,
389 /// The first ancestor containing a [`PROJECT_MARKERS`] entry.
390 boundary: PathBuf,
391 },
392}
393
394/// Returns `Ok(())` iff creating a `.sqry/graph` under `requested` is
395/// safe — i.e. there is no ancestor graph belonging to the same
396/// project boundary, OR the caller passed `allow_nested = true`.
397///
398/// Called by `sqry-cli/src/commands/index.rs::run_index` and by
399/// `FilesystemGraphProvider::acquire`'s
400/// `MissingGraphPolicy::AutoBuildIfEnabled` branch (cluster-E
401/// Layer-2).
402///
403/// The condition `requested.starts_with(&boundary)` is the
404/// load-bearing predicate: a graph at `~/.sqry/graph` and a project
405/// at `~/work/proj/.sqry/graph` do NOT share a boundary (the
406/// project's `Cargo.toml` is at `~/work/proj`, not at `~`), so the
407/// guard does not fire for the legitimate "different project" case.
408/// It fires only for the "same project, nested location" case.
409///
410/// # Errors
411///
412/// Returns [`NestedIndexError::AncestorExists`] when an ancestor
413/// graph belongs to the same project boundary as `requested`.
414pub fn assert_no_ancestor_graph(
415 requested: &Path,
416 allow_nested: bool,
417) -> Result<(), NestedIndexError> {
418 if allow_nested {
419 return Ok(());
420 }
421 // Canonicalise *before* delegating so a relative path like `.`
422 // resolves to an absolute path. If `canonicalize` fails (path does
423 // not exist yet), fall back to joining against the caller's cwd —
424 // this preserves the "act on the caller's directory, not the
425 // process's" intent for sub-process and test-harness invocations
426 // where `current.canonicalize()` would walk into an unrelated
427 // ancestor.
428 let canonical_requested = canonicalise_or_join_cwd(requested);
429 // Refuse to evaluate against a still-relative or empty path —
430 // discover_workspace_root would walk `current.join(".sqry/graph")`
431 // relative to the process cwd, producing the cluster-E §E.3
432 // spurious-error mode reported on 2026-05-10.
433 if !canonical_requested.is_absolute() || canonical_requested.as_os_str().is_empty() {
434 return Ok(());
435 }
436 if let WorkspaceRootDiscovery::GraphFound { root, boundary, .. } =
437 discover_workspace_root(&canonical_requested)
438 && canonical_requested != root
439 && canonical_requested.starts_with(&boundary)
440 {
441 return Err(NestedIndexError::AncestorExists {
442 requested: canonical_requested,
443 ancestor_graph: root.join(".sqry").join("graph"),
444 boundary,
445 });
446 }
447 Ok(())
448}
449
450/// Canonicalise `path`, falling back to `cwd.join(path)` (also
451/// canonicalised) when the path does not exist on disk yet — the
452/// `assert_no_ancestor_graph` caller is by definition about to create
453/// the directory, so a strict canonicalise would always fail in the
454/// happy path.
455fn canonicalise_or_join_cwd(path: &Path) -> PathBuf {
456 if let Ok(canon) = path.canonicalize() {
457 return canon;
458 }
459 if path.is_absolute() {
460 return path.to_path_buf();
461 }
462 let Ok(cwd) = std::env::current_dir() else {
463 return path.to_path_buf();
464 };
465 let joined = cwd.join(path);
466 joined.canonicalize().unwrap_or(joined)
467}
468
469// ───────────────────────────────────────────────────────────────────────
470// `WorkspaceCleanReport` types (sqry-mcp flakiness P1, cluster E
471// foundation — `E_p1_cluster.md` §E.4 + Hand-off E4)
472// ───────────────────────────────────────────────────────────────────────
473//
474// Public types for `sqry workspace clean`'s `--json` output. Stable
475// across patch releases inside `schema_version 1`; additive changes
476// only. Cluster-E Layer-2 owns the discovery + serialization logic;
477// foundation only owns the type shape.
478
479/// Top-level JSON shape produced by `sqry workspace clean --json`.
480/// `schema_version 1` is the wire contract for this release; future
481/// additive fields can be added without bumping the version, but a
482/// breaking field rename or removal must increment to `schema_version 2`.
483#[derive(Debug, Clone, serde::Serialize)]
484pub struct WorkspaceCleanReport {
485 /// Always `1` for this release.
486 pub schema_version: u32,
487 /// Canonicalised root path the cleanup walked.
488 pub root: PathBuf,
489 /// The `.sqry/graph` at the project boundary for `root`, if any.
490 /// Populated by [`discover_workspace_root`]; never auto-deleted
491 /// without `--force`.
492 pub canonical_active_artifact: Option<PathBuf>,
493 /// Daemon-locked artifacts surfaced via the `daemon/active-artifacts`
494 /// IPC method. Empty when the daemon is unreachable; the JSON
495 /// envelope additionally surfaces a fallback warning in that
496 /// case (cluster-E Layer-2 owns the warning shape).
497 pub daemon_locked_artifacts: Vec<PathBuf>,
498 /// Every artifact the cleanup walk found, classified.
499 pub discovered: Vec<DiscoveredArtifact>,
500 /// Subset of `discovered` that the policy filter is willing to
501 /// remove (post `is_canonical_active` / `is_daemon_locked` /
502 /// `is_user_state` filtering).
503 pub planned_removals: Vec<PathBuf>,
504 /// Discovered but not planned for removal, with the reason.
505 pub skipped: Vec<SkippedArtifact>,
506 /// Mirrors the `--apply` flag.
507 pub applied: bool,
508 /// Empty when `applied = false`; otherwise the actually-removed
509 /// paths from `planned_removals` (subset on per-entry I/O error).
510 pub removed: Vec<PathBuf>,
511 /// Per-entry removal failures during `--apply`.
512 pub errors: Vec<RemovalError>,
513}
514
515/// A single artifact discovered by the cleanup walk, with the
516/// classification + size + freshness data needed by the dry-run
517/// summary.
518#[derive(Debug, Clone, serde::Serialize)]
519pub struct DiscoveredArtifact {
520 /// Canonicalised absolute path.
521 pub path: PathBuf,
522 /// Classification of this artifact (graph / cache / user state / ...).
523 pub kind: ArtifactKind,
524 /// Sum of all files within (capped at 10 MiB sample for cache
525 /// directories with millions of entries; see `E_p1_cluster.md`
526 /// §E.4 step 5c).
527 pub size_bytes: u64,
528 /// Last-modified time of the artifact root (best-effort; `None`
529 /// when the filesystem does not expose mtime).
530 pub last_modified: Option<chrono::DateTime<chrono::Utc>>,
531 /// `true` when this is the project's canonical
532 /// `.sqry/graph` artifact — never auto-deleted.
533 pub is_canonical_active: bool,
534 /// `true` when the daemon currently has this artifact loaded
535 /// (per the `daemon/active-artifacts` IPC method).
536 pub is_daemon_locked: bool,
537 /// `true` when this is `.sqry-index.user` — user-curated state
538 /// hidden behind `--include-user-state`.
539 pub is_user_state: bool,
540}
541
542/// Classification of a discovered artifact. The kind drives the
543/// policy filter:
544///
545/// | Kind | Default behaviour without flags |
546/// |------------------|-----------------------------------------|
547/// | `Graph` | Skipped if `is_canonical_active` (or `is_daemon_locked`) without `--force` |
548/// | `GraphRoot` | Same as `Graph` |
549/// | `Cache` | Removed |
550/// | `Prof` | Removed |
551/// | `UserState` | Skipped without `--include-user-state` |
552/// | `LegacyIndex` | Removed |
553/// | `WorkspaceRegistry` | Always skipped (never auto-deleted) |
554/// | `NestedGraph` | Removed unless `is_daemon_locked` |
555#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
556pub enum ArtifactKind {
557 /// `<root>/.sqry/graph` — canonical unified graph snapshot.
558 Graph,
559 /// `<root>/.sqry/` — parent of `Graph`. Listed separately so the
560 /// dry-run can show "graph + cache + manifest" in one entry.
561 GraphRoot,
562 /// `<root>/.sqry-cache` — incremental indexer cache.
563 Cache,
564 /// `<root>/.sqry-prof` — profiler dumps (legacy / external).
565 Prof,
566 /// `<root>/.sqry-index.user` — user-curated state (aliases,
567 /// recent queries). NEVER auto-deleted.
568 UserState,
569 /// `<root>/.sqry-index` — legacy v1 index marker file. Stale
570 /// since v2.0.0; safe to delete unconditionally.
571 LegacyIndex,
572 /// `<root>/.sqry-workspace` — multi-repo registry. NEVER
573 /// auto-deleted.
574 WorkspaceRegistry,
575 /// `.sqry/graph` discovered inside an outer project that already
576 /// has its own canonical graph (E.3 nested-index pollution).
577 NestedGraph,
578}
579
580/// Discovered but not planned for removal — `reason` carries the
581/// policy verdict so the dry-run output can explain why.
582#[derive(Debug, Clone, serde::Serialize)]
583pub struct SkippedArtifact {
584 /// Canonicalised path of the skipped artifact.
585 pub path: PathBuf,
586 /// Why the policy filter skipped this entry.
587 pub reason: SkipReason,
588}
589
590/// Why a discovered artifact was skipped from `planned_removals`.
591#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
592pub enum SkipReason {
593 /// Path equals the project's current canonical
594 /// `.sqry/graph` artifact.
595 CanonicalActive,
596 /// Daemon currently has the artifact loaded.
597 DaemonLocked,
598 /// `.sqry-index.user` without `--include-user-state`.
599 UserState,
600 /// `.sqry-workspace` registry — never auto-deleted.
601 WorkspaceRegistry,
602 /// Symlink that the walker refused to follow.
603 SymlinkRefused,
604 /// Path canonicalised outside the cleanup `root`.
605 OutsideRoot,
606}
607
608/// Per-entry removal failure (e.g. `EACCES` on `remove_dir_all`).
609#[derive(Debug, Clone, serde::Serialize)]
610pub struct RemovalError {
611 /// Canonicalised path the cleanup tried to remove.
612 pub path: PathBuf,
613 /// `Display` form of the underlying I/O error.
614 pub error: String,
615}
616
617#[cfg(test)]
618mod ancestor_tests {
619 use super::*;
620 use tempfile::TempDir;
621
622 /// Sanity: discovery returns `None` for a deeply-nested empty
623 /// hierarchy (no markers, no graphs).
624 #[test]
625 fn discover_returns_none_for_empty_hierarchy() {
626 let tmp = TempDir::new().unwrap();
627 let leaf = tmp.path().join("a/b/c");
628 std::fs::create_dir_all(&leaf).unwrap();
629 let outcome = discover_workspace_root(&leaf);
630 // Either None (filesystem root above tmp has no marker) or
631 // BoundaryOnly (the filesystem root happens to host a
632 // marker like `.git`). Both are acceptable here; the test
633 // pins that GraphFound is NOT returned without a graph.
634 assert!(
635 !matches!(outcome, WorkspaceRootDiscovery::GraphFound { .. }),
636 "no .sqry/graph above leaf, expected None or BoundaryOnly, got {outcome:?}"
637 );
638 }
639
640 /// `Cargo.toml` at the project root halts the walk and the
641 /// outcome is `BoundaryOnly` when no graph exists.
642 #[test]
643 fn discover_stops_at_cargo_toml_marker_with_no_graph() {
644 let tmp = TempDir::new().unwrap();
645 let proj = tmp.path().join("proj");
646 let sub = proj.join("sub/deep");
647 std::fs::create_dir_all(&sub).unwrap();
648 std::fs::write(proj.join("Cargo.toml"), "[package]\n").unwrap();
649 let outcome = discover_workspace_root(&sub);
650 match outcome {
651 WorkspaceRootDiscovery::BoundaryOnly { boundary, .. } => {
652 assert_eq!(
653 boundary.canonicalize().unwrap(),
654 proj.canonicalize().unwrap(),
655 "boundary must equal proj root"
656 );
657 }
658 other => panic!("expected BoundaryOnly, got {other:?}"),
659 }
660 }
661
662 /// `.sqry/graph` inside the project boundary returns
663 /// `GraphFound` with both fields populated.
664 #[test]
665 fn discover_returns_graph_found_when_graph_inside_boundary() {
666 let tmp = TempDir::new().unwrap();
667 let proj = tmp.path().join("proj");
668 let sub = proj.join("sub");
669 std::fs::create_dir_all(&sub).unwrap();
670 std::fs::write(proj.join("Cargo.toml"), "[package]\n").unwrap();
671 std::fs::create_dir_all(proj.join(".sqry").join("graph")).unwrap();
672 let outcome = discover_workspace_root(&sub);
673 match outcome {
674 WorkspaceRootDiscovery::GraphFound { root, boundary, .. } => {
675 assert_eq!(root.canonicalize().unwrap(), proj.canonicalize().unwrap());
676 assert_eq!(
677 boundary.canonicalize().unwrap(),
678 proj.canonicalize().unwrap()
679 );
680 }
681 other => panic!("expected GraphFound, got {other:?}"),
682 }
683 }
684
685 /// Stray `~/.sqry/graph` outside the project boundary does NOT
686 /// satisfy `GraphFound` — the project marker wins. (The exact
687 /// reproducer from `E_p1_cluster.md` §E.1 "stray ~/.sqry/graph".)
688 #[test]
689 fn discover_discards_outer_graph_when_inner_marker_exists() {
690 let tmp = TempDir::new().unwrap();
691 let outer = tmp.path();
692 std::fs::create_dir_all(outer.join(".sqry").join("graph")).unwrap();
693 let proj = outer.join("work/new-project");
694 std::fs::create_dir_all(&proj).unwrap();
695 std::fs::write(proj.join("Cargo.toml"), "[package]\n").unwrap();
696 let outcome = discover_workspace_root(&proj);
697 match outcome {
698 WorkspaceRootDiscovery::BoundaryOnly { boundary, .. } => {
699 assert_eq!(
700 boundary.canonicalize().unwrap(),
701 proj.canonicalize().unwrap(),
702 "boundary should be the inner project root, not the outer stray graph"
703 );
704 }
705 other => {
706 panic!("outer-graph + inner-marker must collapse to BoundaryOnly, got {other:?}")
707 }
708 }
709 }
710
711 /// `assert_no_ancestor_graph(requested, false)` rejects nested
712 /// `.sqry/graph` creation when the same project already has one.
713 #[test]
714 fn assert_no_ancestor_graph_rejects_nested_creation() {
715 let tmp = TempDir::new().unwrap();
716 let proj = tmp.path().join("proj");
717 std::fs::create_dir_all(proj.join(".sqry").join("graph")).unwrap();
718 std::fs::write(proj.join("Cargo.toml"), "[package]\n").unwrap();
719 let nested = proj.join("sub");
720 std::fs::create_dir_all(&nested).unwrap();
721 let err = assert_no_ancestor_graph(&nested, false)
722 .expect_err("nested creation must error when ancestor graph exists");
723 assert!(matches!(err, NestedIndexError::AncestorExists { .. }));
724 }
725
726 /// `allow_nested = true` bypasses the guard.
727 #[test]
728 fn assert_no_ancestor_graph_passes_with_allow_nested() {
729 let tmp = TempDir::new().unwrap();
730 let proj = tmp.path().join("proj");
731 std::fs::create_dir_all(proj.join(".sqry").join("graph")).unwrap();
732 std::fs::write(proj.join("Cargo.toml"), "[package]\n").unwrap();
733 let nested = proj.join("sub");
734 std::fs::create_dir_all(&nested).unwrap();
735 assert!(assert_no_ancestor_graph(&nested, true).is_ok());
736 }
737}