Skip to main content

sqry_cli/commands/
workspace_clean.rs

1//! `sqry workspace clean <root>` — discover and (optionally) remove
2//! stale `.sqry/`, `.sqry-cache`, `.sqry-prof`, and legacy
3//! `.sqry-index` artifacts (cluster-E §E.4).
4//!
5//! Dry-run-by-default. The command emits a [`WorkspaceCleanReport`]
6//! summarising every artifact below `<root>`, classifies each, and
7//! prints the planned-removal set. Pass `--apply` to actually delete
8//! the planned set; the canonical active artifact and any artifact
9//! the running daemon currently has loaded are excluded unless
10//! `--force` is also passed. `.sqry-index.user` (user-curated state —
11//! aliases, recent queries) is excluded unless `--include-user-state`.
12//!
13//! ## Safety
14//!
15//! - `walkdir` is constructed with `follow_links(false)`. A symlink
16//!   that resolves to a `.sqry/`-shaped target is recorded as
17//!   `SkippedArtifact { reason: SymlinkRefused }` and never deleted.
18//! - Every discovered path is canonicalised; entries whose canonical
19//!   form does not start with `canonical(root)` land under
20//!   `SkippedArtifact { reason: OutsideRoot }`.
21//! - Removal uses `fs::remove_dir_all` / `fs::remove_file` only on
22//!   canonicalised absolute paths.
23//!
24//! ## Daemon hand-off
25//!
26//! Before discovery, the command queries the running daemon's
27//! `daemon/active-artifacts` IPC method (250 ms budget) for the list
28//! of `.sqry/graph` directories currently loaded. Those paths get
29//! `is_daemon_locked = true` in the report and are excluded from the
30//! removal plan unless `--force` is passed. When the daemon is down
31//! the list is treated as empty and a warning surfaces in the JSON
32//! envelope.
33
34use std::collections::HashSet;
35use std::fs;
36use std::io::{IsTerminal, Write};
37use std::path::{Path, PathBuf};
38use std::time::{Duration, SystemTime, UNIX_EPOCH};
39
40use anyhow::{Context, Result};
41use sqry_core::workspace::{
42    ArtifactKind, DiscoveredArtifact, RemovalError, SkipReason, SkippedArtifact,
43    WorkspaceCleanReport, WorkspaceRootDiscovery, discover_workspace_root,
44};
45
46use crate::args::Cli;
47
48/// Daemon `daemon/active-artifacts` budget (per cluster-E §E.4 step 3).
49const DAEMON_ARTIFACTS_TIMEOUT_MS: u64 = 250;
50/// Walkdir depth cap; mirrors `sqry_core::workspace::MAX_ANCESTOR_DEPTH`.
51const WALK_MAX_DEPTH: usize = 64;
52
53/// Entry point for `sqry workspace clean`.
54///
55/// # Errors
56///
57/// Returns an error if the root path is invalid, if `--apply` removal
58/// fails for the entire planned set, or if the JSON renderer fails.
59/// Per-entry removal failures are accumulated in
60/// [`WorkspaceCleanReport::errors`] and do not abort the run.
61pub fn run(
62    _cli: &Cli,
63    root: &str,
64    apply: bool,
65    force: bool,
66    include_user_state: bool,
67    json: bool,
68) -> Result<()> {
69    let root_input = PathBuf::from(root);
70    let canonical_root = root_input
71        .canonicalize()
72        .with_context(|| format!("workspace clean: cannot canonicalise root {root_input:?}"))?;
73    if !canonical_root.is_dir() {
74        anyhow::bail!(
75            "workspace clean: root {} is not a directory",
76            canonical_root.display()
77        );
78    }
79
80    // Step 1: identify the canonical active artifact for this root via
81    // the shared workspace walker (cluster-E §E.1). When the walker
82    // returns a graph below the project boundary, that path is the
83    // "do not delete by default" anchor.
84    let canonical_active_artifact = match discover_workspace_root(&canonical_root) {
85        WorkspaceRootDiscovery::GraphFound { root: r, .. } => Some(r.join(".sqry").join("graph")),
86        _ => None,
87    };
88
89    // Step 2: probe the daemon for active artifacts. 250 ms budget; on
90    // timeout / connection failure the list is empty plus a warning
91    // surfaced in the JSON envelope.
92    let (daemon_locked_artifacts, daemon_warning) = probe_daemon_active_artifacts();
93
94    // Step 3: walk the tree.
95    let (discovered, mut skipped) = walk_artifacts(
96        &canonical_root,
97        canonical_active_artifact.as_deref(),
98        &daemon_locked_artifacts,
99    )?;
100
101    // Step 4: filter to planned removals per the §E.4 step-6 policy.
102    let mut planned_removals: Vec<PathBuf> = Vec::new();
103    for art in &discovered {
104        if art.is_canonical_active && !force {
105            skipped.push(SkippedArtifact {
106                path: art.path.clone(),
107                reason: SkipReason::CanonicalActive,
108            });
109            continue;
110        }
111        if art.is_daemon_locked && !force {
112            skipped.push(SkippedArtifact {
113                path: art.path.clone(),
114                reason: SkipReason::DaemonLocked,
115            });
116            continue;
117        }
118        if matches!(art.kind, ArtifactKind::WorkspaceRegistry) {
119            skipped.push(SkippedArtifact {
120                path: art.path.clone(),
121                reason: SkipReason::WorkspaceRegistry,
122            });
123            continue;
124        }
125        if matches!(art.kind, ArtifactKind::UserState) && !include_user_state {
126            skipped.push(SkippedArtifact {
127                path: art.path.clone(),
128                reason: SkipReason::UserState,
129            });
130            continue;
131        }
132        planned_removals.push(art.path.clone());
133    }
134
135    // Step 5: confirmation gating (cluster-E iter-2 §E.4 fix).
136    //
137    // Truth table:
138    //   --apply           → text/TTY: prompt; text/non-TTY: apply.
139    //   --apply --force   → always apply (force opts out of all gates).
140    //   --apply --json    → REFUSE to apply when confirmation would
141    //                       normally be required (TTY) — JSON mode
142    //                       must never prompt and must never silently
143    //                       apply unconfirmed removals. Combine with
144    //                       `--force` for non-interactive scripted
145    //                       removal.
146    //   --apply --json --force → apply (force is the explicit
147    //                       non-interactive opt-in).
148    let mut removed: Vec<PathBuf> = Vec::new();
149    let mut errors: Vec<RemovalError> = Vec::new();
150    let mut effective_apply = apply;
151    if apply && !force && !planned_removals.is_empty() {
152        if json {
153            // JSON callers must opt in via --force; record the
154            // refusal as a per-entry error and demote to dry-run.
155            for path in &planned_removals {
156                errors.push(RemovalError {
157                    path: path.clone(),
158                    error: "skipped: --apply --json requires --force \
159                            (JSON mode never prompts; pass --force to \
160                            confirm non-interactive removal)"
161                        .to_string(),
162                });
163            }
164            effective_apply = false;
165        } else if std::io::stdin().is_terminal() && !confirm_removal(&planned_removals)? {
166            // User declined.
167            effective_apply = false;
168        }
169        // Non-TTY text mode without --force preserves the existing
170        // contract: apply silently. Pipelines + sudo flows stay
171        // unchanged.
172    }
173    if effective_apply {
174        for path in &planned_removals {
175            match remove_path(path) {
176                Ok(()) => removed.push(path.clone()),
177                Err(e) => errors.push(RemovalError {
178                    path: path.clone(),
179                    error: e.to_string(),
180                }),
181            }
182        }
183    }
184
185    let report = WorkspaceCleanReport {
186        schema_version: 1,
187        root: canonical_root,
188        canonical_active_artifact,
189        daemon_locked_artifacts,
190        discovered,
191        planned_removals,
192        skipped,
193        // `applied` reflects what we ACTUALLY did, not what the user
194        // asked for. The JSON-mode-without-force gate above demotes
195        // `--apply` to a dry-run; surface that on the wire.
196        applied: effective_apply,
197        removed,
198        errors,
199    };
200    emit_report(report, json, daemon_warning)
201}
202
203/// Render the report. JSON mode emits the canonical schema verbatim
204/// (plus an optional `_warning` field for daemon-down state); text
205/// mode prints a human-readable summary.
206fn emit_report(
207    report: WorkspaceCleanReport,
208    json: bool,
209    daemon_warning: Option<&'static str>,
210) -> Result<()> {
211    if json {
212        let mut value = serde_json::to_value(&report)
213            .context("workspace clean: failed to serialise WorkspaceCleanReport")?;
214        if let (Some(warning), Some(obj)) = (daemon_warning, value.as_object_mut()) {
215            obj.insert(
216                "_warning".to_string(),
217                serde_json::Value::String(warning.to_string()),
218            );
219        }
220        let pretty = serde_json::to_string_pretty(&value)
221            .context("workspace clean: failed to render JSON")?;
222        println!("{pretty}");
223        return Ok(());
224    }
225
226    print_text_summary(&report, daemon_warning);
227    Ok(())
228}
229
230fn print_text_summary(report: &WorkspaceCleanReport, daemon_warning: Option<&'static str>) {
231    println!("sqry workspace clean — root: {}", report.root.display());
232    if let Some(active) = &report.canonical_active_artifact {
233        println!("  canonical active: {}", active.display());
234    }
235    if let Some(w) = daemon_warning {
236        println!("  warning: {w}");
237    }
238    println!();
239    println!("Discovered ({} entries):", report.discovered.len());
240    for art in &report.discovered {
241        let mut tags: Vec<&'static str> = Vec::new();
242        if art.is_canonical_active {
243            tags.push("active");
244        }
245        if art.is_daemon_locked {
246            tags.push("daemon-locked");
247        }
248        if art.is_user_state {
249            tags.push("user-state");
250        }
251        let tag_str = if tags.is_empty() {
252            String::new()
253        } else {
254            format!(" [{}]", tags.join(", "))
255        };
256        println!(
257            "  {kind:?}  {size_kib:>8} KiB  {path}{tag}",
258            kind = art.kind,
259            size_kib = art.size_bytes / 1024,
260            path = art.path.display(),
261            tag = tag_str,
262        );
263    }
264    println!();
265    if report.planned_removals.is_empty() {
266        println!("No removable artifacts under this policy.");
267    } else {
268        println!(
269            "Planned removals ({} entries):",
270            report.planned_removals.len()
271        );
272        for p in &report.planned_removals {
273            println!("  - {}", p.display());
274        }
275    }
276    if !report.skipped.is_empty() {
277        println!();
278        println!("Skipped ({} entries):", report.skipped.len());
279        for s in &report.skipped {
280            println!("  {} ({:?})", s.path.display(), s.reason);
281        }
282    }
283    if report.applied {
284        println!();
285        println!(
286            "Applied: removed {} of {} planned artifacts.",
287            report.removed.len(),
288            report.planned_removals.len(),
289        );
290        if !report.errors.is_empty() {
291            println!("Errors ({}):", report.errors.len());
292            for err in &report.errors {
293                println!("  {} — {}", err.path.display(), err.error);
294            }
295        }
296    } else {
297        println!();
298        println!("DRY RUN — re-run with --apply to remove the planned artifacts.");
299    }
300}
301
302fn confirm_removal(planned: &[PathBuf]) -> Result<bool> {
303    eprintln!(
304        "sqry: about to remove {} artifact(s). Continue? [y/N] ",
305        planned.len()
306    );
307    std::io::stderr().flush().ok();
308    let mut buf = String::new();
309    std::io::stdin()
310        .read_line(&mut buf)
311        .context("workspace clean: failed to read confirmation")?;
312    let trimmed = buf.trim().to_ascii_lowercase();
313    Ok(matches!(trimmed.as_str(), "y" | "yes"))
314}
315
316/// Walk `root` collecting every `.sqry/`, `.sqry-cache`,
317/// `.sqry-prof`, `.sqry-index`, `.sqry-index.user`, and
318/// `.sqry-workspace` entry. Returns `(discovered, skipped)` —
319/// canonicalisation failures land in `skipped` so the dry run can
320/// still account for them without blowing up the whole walk.
321fn walk_artifacts(
322    canonical_root: &Path,
323    canonical_active_artifact: Option<&Path>,
324    daemon_locked: &[PathBuf],
325) -> Result<(Vec<DiscoveredArtifact>, Vec<SkippedArtifact>)> {
326    let mut discovered: Vec<DiscoveredArtifact> = Vec::new();
327    let mut skipped: Vec<SkippedArtifact> = Vec::new();
328    let daemon_set: HashSet<PathBuf> = daemon_locked
329        .iter()
330        .map(|p| p.canonicalize().unwrap_or_else(|_| p.clone()))
331        .collect();
332    // Track every directory that has already been classified as an
333    // artifact root so we can prune children (avoid double-counting
334    // contents of `.sqry-cache` etc.).
335    let mut pruned: HashSet<PathBuf> = HashSet::new();
336
337    let mut walker = walkdir::WalkDir::new(canonical_root)
338        .follow_links(false)
339        .max_depth(WALK_MAX_DEPTH)
340        .into_iter();
341
342    while let Some(entry_result) = walker.next() {
343        let entry = match entry_result {
344            Ok(e) => e,
345            Err(e) => {
346                // Surface the path if walkdir gives one; otherwise just a
347                // synthetic skipped entry under the root.
348                let p = e
349                    .path()
350                    .map_or_else(|| canonical_root.to_path_buf(), Path::to_path_buf);
351                skipped.push(SkippedArtifact {
352                    path: p,
353                    reason: SkipReason::OutsideRoot,
354                });
355                continue;
356            }
357        };
358        let path = entry.path();
359
360        // Prune children of an already-classified artifact directory.
361        if pruned.iter().any(|p| path.starts_with(p)) {
362            continue;
363        }
364
365        // Only directory entries (and the legacy `.sqry-index` file)
366        // can be artifacts. File traversal still produces entries; we
367        // skip them quickly here.
368        let file_name = match path.file_name().and_then(|n| n.to_str()) {
369            Some(n) => n,
370            None => continue,
371        };
372        let kind = match file_name {
373            ".sqry" if entry.file_type().is_dir() => ArtifactKind::GraphRoot,
374            ".sqry-cache" if entry.file_type().is_dir() => ArtifactKind::Cache,
375            ".sqry-prof" if entry.file_type().is_dir() => ArtifactKind::Prof,
376            ".sqry-index" if entry.file_type().is_file() => ArtifactKind::LegacyIndex,
377            ".sqry-index.user" if entry.file_type().is_file() => ArtifactKind::UserState,
378            ".sqry-workspace" if entry.file_type().is_file() => ArtifactKind::WorkspaceRegistry,
379            _ => continue,
380        };
381
382        // Symlink defence — `walkdir` won't follow links because we set
383        // `follow_links(false)`, but a directory entry whose own
384        // metadata is a symlink should be refused outright.
385        if entry.path_is_symlink() {
386            skipped.push(SkippedArtifact {
387                path: path.to_path_buf(),
388                reason: SkipReason::SymlinkRefused,
389            });
390            // Don't descend into linked target; walkdir already won't.
391            walker.skip_current_dir();
392            continue;
393        }
394
395        // Path-traversal defence — canonicalise and verify the result
396        // stays under `canonical_root`. A `.sqry-cache` symlink that
397        // points outside should already be caught above, but defence
398        // in depth.
399        let canonical_path = match path.canonicalize() {
400            Ok(p) => p,
401            Err(_) => {
402                skipped.push(SkippedArtifact {
403                    path: path.to_path_buf(),
404                    reason: SkipReason::OutsideRoot,
405                });
406                if entry.file_type().is_dir() {
407                    walker.skip_current_dir();
408                }
409                continue;
410            }
411        };
412        if !canonical_path.starts_with(canonical_root) {
413            skipped.push(SkippedArtifact {
414                path: canonical_path,
415                reason: SkipReason::OutsideRoot,
416            });
417            if entry.file_type().is_dir() {
418                walker.skip_current_dir();
419            }
420            continue;
421        }
422
423        let size_bytes = match kind {
424            ArtifactKind::Graph
425            | ArtifactKind::GraphRoot
426            | ArtifactKind::Cache
427            | ArtifactKind::Prof
428            | ArtifactKind::NestedGraph => directory_size(&canonical_path),
429            ArtifactKind::LegacyIndex
430            | ArtifactKind::UserState
431            | ArtifactKind::WorkspaceRegistry => {
432                fs::metadata(&canonical_path).map(|m| m.len()).unwrap_or(0)
433            }
434        };
435        let last_modified = fs::metadata(&canonical_path)
436            .ok()
437            .and_then(|m| m.modified().ok())
438            .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
439            .and_then(|d| {
440                let secs = i64::try_from(d.as_secs()).ok()?;
441                chrono::DateTime::<chrono::Utc>::from_timestamp(secs, d.subsec_nanos())
442            });
443
444        // For `GraphRoot`, also surface the inner `graph/` as the
445        // canonical-active marker target (the active artifact is the
446        // `graph/` directory, not its parent).
447        let inner_graph = canonical_path.join("graph");
448        let is_canonical_active = canonical_active_artifact
449            .is_some_and(|a| a == canonical_path.as_path() || a == inner_graph.as_path());
450        let is_daemon_locked = daemon_set
451            .iter()
452            .any(|p| *p == canonical_path || *p == inner_graph);
453        let is_user_state = matches!(kind, ArtifactKind::UserState);
454
455        // If a `.sqry/` lives inside an outer project boundary distinct
456        // from this `canonical_root`, classify as `NestedGraph`.
457        // Detection: the `discover_workspace_root` of the parent dir
458        // returns a different `root` than `canonical_path`.
459        let final_kind = if matches!(kind, ArtifactKind::GraphRoot) && !is_canonical_active {
460            match canonical_path.parent() {
461                Some(parent) => match discover_workspace_root(parent) {
462                    WorkspaceRootDiscovery::GraphFound { root: r, .. }
463                        if r.join(".sqry") != canonical_path =>
464                    {
465                        ArtifactKind::NestedGraph
466                    }
467                    _ => ArtifactKind::GraphRoot,
468                },
469                None => ArtifactKind::GraphRoot,
470            }
471        } else {
472            kind
473        };
474
475        discovered.push(DiscoveredArtifact {
476            path: canonical_path.clone(),
477            kind: final_kind,
478            size_bytes,
479            last_modified,
480            is_canonical_active,
481            is_daemon_locked,
482            is_user_state,
483        });
484
485        // Don't descend into the artifact root itself; everything below
486        // belongs to the same logical artifact.
487        if entry.file_type().is_dir() {
488            pruned.insert(canonical_path);
489            walker.skip_current_dir();
490        }
491    }
492
493    Ok((discovered, skipped))
494}
495
496/// Recursive size in bytes. Best-effort: any I/O failure yields 0 for
497/// that subtree rather than aborting the dry run.
498fn directory_size(root: &Path) -> u64 {
499    let mut total: u64 = 0;
500    for entry in walkdir::WalkDir::new(root).follow_links(false) {
501        let Ok(entry) = entry else { continue };
502        if entry.file_type().is_file()
503            && let Ok(meta) = entry.metadata()
504        {
505            total = total.saturating_add(meta.len());
506        }
507    }
508    total
509}
510
511fn remove_path(path: &Path) -> std::io::Result<()> {
512    let meta = fs::symlink_metadata(path)?;
513    if meta.is_dir() {
514        fs::remove_dir_all(path)
515    } else {
516        fs::remove_file(path)
517    }
518}
519
520/// Connect to the daemon, send `daemon/active-artifacts`, return the
521/// list (or empty + warning on timeout / connection failure).
522fn probe_daemon_active_artifacts() -> (Vec<PathBuf>, Option<&'static str>) {
523    let socket_path = match sqry_daemon::config::DaemonConfig::load() {
524        Ok(cfg) => cfg.socket_path(),
525        Err(_) => {
526            return (
527                Vec::new(),
528                Some("daemon config not loadable; daemon-locked check skipped"),
529            );
530        }
531    };
532    if !crate::commands::daemon::try_connect_sync(&socket_path).unwrap_or(false) {
533        return (
534            Vec::new(),
535            Some("sqryd is not running; daemon-locked check skipped"),
536        );
537    }
538    let rt = match tokio::runtime::Builder::new_current_thread()
539        .enable_all()
540        .build()
541    {
542        Ok(r) => r,
543        Err(_) => {
544            return (
545                Vec::new(),
546                Some("could not start tokio runtime to probe daemon; check skipped"),
547            );
548        }
549    };
550    rt.block_on(async {
551        let timeout = Duration::from_millis(DAEMON_ARTIFACTS_TIMEOUT_MS);
552        let probe = async {
553            let mut client = sqry_daemon_client::DaemonClient::connect(&socket_path).await?;
554            client.active_artifacts().await
555        };
556        match tokio::time::timeout(timeout, probe).await {
557            Ok(Ok(list)) => (list, None),
558            Ok(Err(_)) => (
559                Vec::new(),
560                Some("daemon/active-artifacts request failed; daemon-locked check skipped"),
561            ),
562            Err(_) => (
563                Vec::new(),
564                Some("daemon/active-artifacts timed out at 250ms; daemon-locked check skipped"),
565            ),
566        }
567    })
568}
569
570/// Suppress unused warning for `SystemTime` which we keep around for
571/// future last-modified-via-now diagnostics.
572#[allow(dead_code)]
573fn _assert_time_imports() {
574    let _ = SystemTime::now();
575}
576
577#[cfg(test)]
578mod tests {
579    use super::*;
580    use tempfile::TempDir;
581
582    /// Build a fresh canonical path for a freshly-created tempdir.
583    fn canonical(p: &Path) -> PathBuf {
584        p.canonicalize().unwrap()
585    }
586
587    /// Build a representative artifact layout under `root`:
588    ///
589    /// - `<root>/.sqry/graph/snapshot.sqry` (canonical active)
590    /// - `<root>/.sqry-cache/file`
591    /// - `<root>/.sqry-prof/file`
592    /// - `<root>/.sqry-index` (legacy)
593    /// - `<root>/.sqry-index.user`
594    /// - `<root>/Cargo.toml` (project marker so `discover_workspace_root`
595    ///   anchors the active artifact correctly).
596    fn make_layout(root: &Path) {
597        fs::create_dir_all(root.join(".sqry").join("graph")).unwrap();
598        fs::write(root.join(".sqry").join("graph").join("snapshot.sqry"), b"x").unwrap();
599        fs::create_dir_all(root.join(".sqry-cache")).unwrap();
600        fs::write(root.join(".sqry-cache").join("file"), b"x").unwrap();
601        fs::create_dir_all(root.join(".sqry-prof")).unwrap();
602        fs::write(root.join(".sqry-prof").join("file"), b"x").unwrap();
603        fs::write(root.join(".sqry-index"), b"legacy").unwrap();
604        fs::write(root.join(".sqry-index.user"), b"alias=foo").unwrap();
605        fs::write(root.join("Cargo.toml"), "[package]\n").unwrap();
606    }
607
608    /// Helper: walk + filter without daemon probe, returning the
609    /// (planned, skipped) split that the policy filter produces.
610    fn dry_run(
611        root: &Path,
612        force: bool,
613        include_user_state: bool,
614        daemon_locked: &[PathBuf],
615    ) -> (Vec<DiscoveredArtifact>, Vec<PathBuf>, Vec<SkippedArtifact>) {
616        let canonical_root = canonical(root);
617        let canonical_active = match discover_workspace_root(&canonical_root) {
618            WorkspaceRootDiscovery::GraphFound { root: r, .. } => {
619                Some(r.join(".sqry").join("graph"))
620            }
621            _ => None,
622        };
623        let (discovered, mut skipped) =
624            walk_artifacts(&canonical_root, canonical_active.as_deref(), daemon_locked).unwrap();
625        let mut planned = Vec::new();
626        for art in &discovered {
627            if art.is_canonical_active && !force {
628                skipped.push(SkippedArtifact {
629                    path: art.path.clone(),
630                    reason: SkipReason::CanonicalActive,
631                });
632                continue;
633            }
634            if art.is_daemon_locked && !force {
635                skipped.push(SkippedArtifact {
636                    path: art.path.clone(),
637                    reason: SkipReason::DaemonLocked,
638                });
639                continue;
640            }
641            if matches!(art.kind, ArtifactKind::WorkspaceRegistry) {
642                skipped.push(SkippedArtifact {
643                    path: art.path.clone(),
644                    reason: SkipReason::WorkspaceRegistry,
645                });
646                continue;
647            }
648            if matches!(art.kind, ArtifactKind::UserState) && !include_user_state {
649                skipped.push(SkippedArtifact {
650                    path: art.path.clone(),
651                    reason: SkipReason::UserState,
652                });
653                continue;
654            }
655            planned.push(art.path.clone());
656        }
657        (discovered, planned, skipped)
658    }
659
660    /// §E.4 row 1: dry run lists all five artifact kinds, plans removal of
661    /// `.sqry-cache`, `.sqry-prof`, `.sqry-index`; protects the canonical
662    /// `.sqry/` and `.sqry-index.user`.
663    #[test]
664    fn dry_run_lists_stale() {
665        let tmp = TempDir::new().unwrap();
666        let root = tmp.path().join("proj");
667        fs::create_dir_all(&root).unwrap();
668        make_layout(&root);
669
670        let (discovered, planned, _skipped) = dry_run(&root, false, false, &[]);
671
672        assert_eq!(
673            discovered.len(),
674            5,
675            "expected 5 artifacts (sqry/graph-root, cache, prof, legacy, user-state), got {discovered:?}"
676        );
677        let active = canonical(&root).join(".sqry");
678        assert!(
679            !planned.iter().any(|p| p == &active),
680            "canonical active must be skipped without --force, planned={planned:?}"
681        );
682        let user = canonical(&root).join(".sqry-index.user");
683        assert!(
684            !planned.iter().any(|p| p == &user),
685            "user state must be skipped without --include-user-state, planned={planned:?}"
686        );
687        let must_be_planned = [
688            canonical(&root).join(".sqry-cache"),
689            canonical(&root).join(".sqry-prof"),
690            canonical(&root).join(".sqry-index"),
691        ];
692        for p in must_be_planned {
693            assert!(
694                planned.contains(&p),
695                "{} must be in planned removals, planned={planned:?}",
696                p.display()
697            );
698        }
699    }
700
701    /// §E.4 row 2: removal-via-end-to-end run leaves the canonical
702    /// `.sqry/` and `.sqry-index.user` intact, deletes the rest of the
703    /// planned set.
704    #[test]
705    fn apply_removes_planned_only() {
706        let tmp = TempDir::new().unwrap();
707        let root = tmp.path().join("proj");
708        fs::create_dir_all(&root).unwrap();
709        make_layout(&root);
710
711        let (_discovered, planned, _skipped) = dry_run(&root, false, false, &[]);
712        // Apply: directly invoke the helper's removal step (the public
713        // `run()` would also call the daemon probe; this test isolates
714        // the filesystem effect).
715        for p in &planned {
716            remove_path(p).unwrap();
717        }
718
719        assert!(
720            root.join(".sqry").join("graph").exists(),
721            "canonical active must survive"
722        );
723        assert!(
724            root.join(".sqry-index.user").exists(),
725            "user state must survive"
726        );
727        assert!(!root.join(".sqry-cache").exists());
728        assert!(!root.join(".sqry-prof").exists());
729        assert!(!root.join(".sqry-index").exists());
730    }
731
732    /// §E.4 row 3: `--apply` without `--force` must NOT remove the
733    /// canonical active artifact even when the user explicitly opts
734    /// into removal.
735    #[test]
736    fn apply_protects_canonical_without_force() {
737        let tmp = TempDir::new().unwrap();
738        let root = tmp.path().join("proj");
739        fs::create_dir_all(&root).unwrap();
740        make_layout(&root);
741
742        let (_discovered, planned, _skipped) = dry_run(&root, false, false, &[]);
743        let active = canonical(&root).join(".sqry");
744        assert!(
745            !planned.contains(&active),
746            "without --force the canonical active must not appear in planned removals"
747        );
748    }
749
750    /// §E.4 row 5: an artifact reported by `daemon/active-artifacts` is
751    /// excluded from the planned-removal set when `--force` is absent.
752    #[test]
753    fn daemon_locked_protected() {
754        let tmp = TempDir::new().unwrap();
755        let root = tmp.path().join("proj");
756        fs::create_dir_all(&root).unwrap();
757        make_layout(&root);
758
759        let canonical_graph = canonical(&root).join(".sqry").join("graph");
760        let (discovered, planned, _skipped) =
761            dry_run(&root, false, false, std::slice::from_ref(&canonical_graph));
762
763        // The classifier walks `.sqry/` (the directory containing
764        // `graph/`) — daemon-locked detection covers either
765        // `canonical_graph` or its parent `.sqry/`.
766        let saw_lock = discovered.iter().any(|a| {
767            a.is_daemon_locked && (a.path == canonical_graph || a.path.ends_with(".sqry"))
768        });
769        assert!(
770            saw_lock,
771            "daemon-locked detection must flag .sqry/ when its inner graph/ matches"
772        );
773        assert!(
774            !planned
775                .iter()
776                .any(|p| p == &canonical_graph || p.ends_with(".sqry")),
777            "daemon-locked artifact must be excluded from planned removals, got {planned:?}"
778        );
779    }
780}