1use 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
48const DAEMON_ARTIFACTS_TIMEOUT_MS: u64 = 250;
50const WALK_MAX_DEPTH: usize = 64;
52
53pub 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 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 let (daemon_locked_artifacts, daemon_warning) = probe_daemon_active_artifacts();
93
94 let (discovered, mut skipped) = walk_artifacts(
96 &canonical_root,
97 canonical_active_artifact.as_deref(),
98 &daemon_locked_artifacts,
99 )?;
100
101 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 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 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 effective_apply = false;
168 }
169 }
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: effective_apply,
197 removed,
198 errors,
199 };
200 emit_report(report, json, daemon_warning)
201}
202
203fn 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
316fn 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 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 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 if pruned.iter().any(|p| path.starts_with(p)) {
362 continue;
363 }
364
365 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 if entry.path_is_symlink() {
386 skipped.push(SkippedArtifact {
387 path: path.to_path_buf(),
388 reason: SkipReason::SymlinkRefused,
389 });
390 walker.skip_current_dir();
392 continue;
393 }
394
395 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 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 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 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
496fn 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
520fn 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#[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 fn canonical(p: &Path) -> PathBuf {
584 p.canonicalize().unwrap()
585 }
586
587 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 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 #[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 #[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 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 #[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 #[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 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}