Skip to main content

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}