use std::collections::HashSet;
use std::fs;
use std::io::{IsTerminal, Write};
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use anyhow::{Context, Result};
use sqry_core::workspace::{
ArtifactKind, DiscoveredArtifact, RemovalError, SkipReason, SkippedArtifact,
WorkspaceCleanReport, WorkspaceRootDiscovery, discover_workspace_root,
};
use crate::args::Cli;
const DAEMON_ARTIFACTS_TIMEOUT_MS: u64 = 250;
const WALK_MAX_DEPTH: usize = 64;
pub fn run(
_cli: &Cli,
root: &str,
apply: bool,
force: bool,
include_user_state: bool,
json: bool,
) -> Result<()> {
let root_input = PathBuf::from(root);
let canonical_root = root_input
.canonicalize()
.with_context(|| format!("workspace clean: cannot canonicalise root {root_input:?}"))?;
if !canonical_root.is_dir() {
anyhow::bail!(
"workspace clean: root {} is not a directory",
canonical_root.display()
);
}
let canonical_active_artifact = match discover_workspace_root(&canonical_root) {
WorkspaceRootDiscovery::GraphFound { root: r, .. } => Some(r.join(".sqry").join("graph")),
_ => None,
};
let (daemon_locked_artifacts, daemon_warning) = probe_daemon_active_artifacts();
let (discovered, mut skipped) = walk_artifacts(
&canonical_root,
canonical_active_artifact.as_deref(),
&daemon_locked_artifacts,
)?;
let mut planned_removals: Vec<PathBuf> = Vec::new();
for art in &discovered {
if art.is_canonical_active && !force {
skipped.push(SkippedArtifact {
path: art.path.clone(),
reason: SkipReason::CanonicalActive,
});
continue;
}
if art.is_daemon_locked && !force {
skipped.push(SkippedArtifact {
path: art.path.clone(),
reason: SkipReason::DaemonLocked,
});
continue;
}
if matches!(art.kind, ArtifactKind::WorkspaceRegistry) {
skipped.push(SkippedArtifact {
path: art.path.clone(),
reason: SkipReason::WorkspaceRegistry,
});
continue;
}
if matches!(art.kind, ArtifactKind::UserState) && !include_user_state {
skipped.push(SkippedArtifact {
path: art.path.clone(),
reason: SkipReason::UserState,
});
continue;
}
planned_removals.push(art.path.clone());
}
let mut removed: Vec<PathBuf> = Vec::new();
let mut errors: Vec<RemovalError> = Vec::new();
let mut effective_apply = apply;
if apply && !force && !planned_removals.is_empty() {
if json {
for path in &planned_removals {
errors.push(RemovalError {
path: path.clone(),
error: "skipped: --apply --json requires --force \
(JSON mode never prompts; pass --force to \
confirm non-interactive removal)"
.to_string(),
});
}
effective_apply = false;
} else if std::io::stdin().is_terminal() && !confirm_removal(&planned_removals)? {
effective_apply = false;
}
}
if effective_apply {
for path in &planned_removals {
match remove_path(path) {
Ok(()) => removed.push(path.clone()),
Err(e) => errors.push(RemovalError {
path: path.clone(),
error: e.to_string(),
}),
}
}
}
let report = WorkspaceCleanReport {
schema_version: 1,
root: canonical_root,
canonical_active_artifact,
daemon_locked_artifacts,
discovered,
planned_removals,
skipped,
applied: effective_apply,
removed,
errors,
};
emit_report(report, json, daemon_warning)
}
fn emit_report(
report: WorkspaceCleanReport,
json: bool,
daemon_warning: Option<&'static str>,
) -> Result<()> {
if json {
let mut value = serde_json::to_value(&report)
.context("workspace clean: failed to serialise WorkspaceCleanReport")?;
if let (Some(warning), Some(obj)) = (daemon_warning, value.as_object_mut()) {
obj.insert(
"_warning".to_string(),
serde_json::Value::String(warning.to_string()),
);
}
let pretty = serde_json::to_string_pretty(&value)
.context("workspace clean: failed to render JSON")?;
println!("{pretty}");
return Ok(());
}
print_text_summary(&report, daemon_warning);
Ok(())
}
fn print_text_summary(report: &WorkspaceCleanReport, daemon_warning: Option<&'static str>) {
println!("sqry workspace clean — root: {}", report.root.display());
if let Some(active) = &report.canonical_active_artifact {
println!(" canonical active: {}", active.display());
}
if let Some(w) = daemon_warning {
println!(" warning: {w}");
}
println!();
println!("Discovered ({} entries):", report.discovered.len());
for art in &report.discovered {
let mut tags: Vec<&'static str> = Vec::new();
if art.is_canonical_active {
tags.push("active");
}
if art.is_daemon_locked {
tags.push("daemon-locked");
}
if art.is_user_state {
tags.push("user-state");
}
let tag_str = if tags.is_empty() {
String::new()
} else {
format!(" [{}]", tags.join(", "))
};
println!(
" {kind:?} {size_kib:>8} KiB {path}{tag}",
kind = art.kind,
size_kib = art.size_bytes / 1024,
path = art.path.display(),
tag = tag_str,
);
}
println!();
if report.planned_removals.is_empty() {
println!("No removable artifacts under this policy.");
} else {
println!(
"Planned removals ({} entries):",
report.planned_removals.len()
);
for p in &report.planned_removals {
println!(" - {}", p.display());
}
}
if !report.skipped.is_empty() {
println!();
println!("Skipped ({} entries):", report.skipped.len());
for s in &report.skipped {
println!(" {} ({:?})", s.path.display(), s.reason);
}
}
if report.applied {
println!();
println!(
"Applied: removed {} of {} planned artifacts.",
report.removed.len(),
report.planned_removals.len(),
);
if !report.errors.is_empty() {
println!("Errors ({}):", report.errors.len());
for err in &report.errors {
println!(" {} — {}", err.path.display(), err.error);
}
}
} else {
println!();
println!("DRY RUN — re-run with --apply to remove the planned artifacts.");
}
}
fn confirm_removal(planned: &[PathBuf]) -> Result<bool> {
eprintln!(
"sqry: about to remove {} artifact(s). Continue? [y/N] ",
planned.len()
);
std::io::stderr().flush().ok();
let mut buf = String::new();
std::io::stdin()
.read_line(&mut buf)
.context("workspace clean: failed to read confirmation")?;
let trimmed = buf.trim().to_ascii_lowercase();
Ok(matches!(trimmed.as_str(), "y" | "yes"))
}
fn walk_artifacts(
canonical_root: &Path,
canonical_active_artifact: Option<&Path>,
daemon_locked: &[PathBuf],
) -> Result<(Vec<DiscoveredArtifact>, Vec<SkippedArtifact>)> {
let mut discovered: Vec<DiscoveredArtifact> = Vec::new();
let mut skipped: Vec<SkippedArtifact> = Vec::new();
let daemon_set: HashSet<PathBuf> = daemon_locked
.iter()
.map(|p| p.canonicalize().unwrap_or_else(|_| p.clone()))
.collect();
let mut pruned: HashSet<PathBuf> = HashSet::new();
let mut walker = walkdir::WalkDir::new(canonical_root)
.follow_links(false)
.max_depth(WALK_MAX_DEPTH)
.into_iter();
while let Some(entry_result) = walker.next() {
let entry = match entry_result {
Ok(e) => e,
Err(e) => {
let p = e
.path()
.map_or_else(|| canonical_root.to_path_buf(), Path::to_path_buf);
skipped.push(SkippedArtifact {
path: p,
reason: SkipReason::OutsideRoot,
});
continue;
}
};
let path = entry.path();
if pruned.iter().any(|p| path.starts_with(p)) {
continue;
}
let file_name = match path.file_name().and_then(|n| n.to_str()) {
Some(n) => n,
None => continue,
};
let kind = match file_name {
".sqry" if entry.file_type().is_dir() => ArtifactKind::GraphRoot,
".sqry-cache" if entry.file_type().is_dir() => ArtifactKind::Cache,
".sqry-prof" if entry.file_type().is_dir() => ArtifactKind::Prof,
".sqry-index" if entry.file_type().is_file() => ArtifactKind::LegacyIndex,
".sqry-index.user" if entry.file_type().is_file() => ArtifactKind::UserState,
".sqry-workspace" if entry.file_type().is_file() => ArtifactKind::WorkspaceRegistry,
_ => continue,
};
if entry.path_is_symlink() {
skipped.push(SkippedArtifact {
path: path.to_path_buf(),
reason: SkipReason::SymlinkRefused,
});
walker.skip_current_dir();
continue;
}
let canonical_path = match path.canonicalize() {
Ok(p) => p,
Err(_) => {
skipped.push(SkippedArtifact {
path: path.to_path_buf(),
reason: SkipReason::OutsideRoot,
});
if entry.file_type().is_dir() {
walker.skip_current_dir();
}
continue;
}
};
if !canonical_path.starts_with(canonical_root) {
skipped.push(SkippedArtifact {
path: canonical_path,
reason: SkipReason::OutsideRoot,
});
if entry.file_type().is_dir() {
walker.skip_current_dir();
}
continue;
}
let size_bytes = match kind {
ArtifactKind::Graph
| ArtifactKind::GraphRoot
| ArtifactKind::Cache
| ArtifactKind::Prof
| ArtifactKind::NestedGraph => directory_size(&canonical_path),
ArtifactKind::LegacyIndex
| ArtifactKind::UserState
| ArtifactKind::WorkspaceRegistry => {
fs::metadata(&canonical_path).map(|m| m.len()).unwrap_or(0)
}
};
let last_modified = fs::metadata(&canonical_path)
.ok()
.and_then(|m| m.modified().ok())
.and_then(|t| t.duration_since(UNIX_EPOCH).ok())
.and_then(|d| {
let secs = i64::try_from(d.as_secs()).ok()?;
chrono::DateTime::<chrono::Utc>::from_timestamp(secs, d.subsec_nanos())
});
let inner_graph = canonical_path.join("graph");
let is_canonical_active = canonical_active_artifact
.is_some_and(|a| a == canonical_path.as_path() || a == inner_graph.as_path());
let is_daemon_locked = daemon_set
.iter()
.any(|p| *p == canonical_path || *p == inner_graph);
let is_user_state = matches!(kind, ArtifactKind::UserState);
let final_kind = if matches!(kind, ArtifactKind::GraphRoot) && !is_canonical_active {
match canonical_path.parent() {
Some(parent) => match discover_workspace_root(parent) {
WorkspaceRootDiscovery::GraphFound { root: r, .. }
if r.join(".sqry") != canonical_path =>
{
ArtifactKind::NestedGraph
}
_ => ArtifactKind::GraphRoot,
},
None => ArtifactKind::GraphRoot,
}
} else {
kind
};
discovered.push(DiscoveredArtifact {
path: canonical_path.clone(),
kind: final_kind,
size_bytes,
last_modified,
is_canonical_active,
is_daemon_locked,
is_user_state,
});
if entry.file_type().is_dir() {
pruned.insert(canonical_path);
walker.skip_current_dir();
}
}
Ok((discovered, skipped))
}
fn directory_size(root: &Path) -> u64 {
let mut total: u64 = 0;
for entry in walkdir::WalkDir::new(root).follow_links(false) {
let Ok(entry) = entry else { continue };
if entry.file_type().is_file()
&& let Ok(meta) = entry.metadata()
{
total = total.saturating_add(meta.len());
}
}
total
}
fn remove_path(path: &Path) -> std::io::Result<()> {
let meta = fs::symlink_metadata(path)?;
if meta.is_dir() {
fs::remove_dir_all(path)
} else {
fs::remove_file(path)
}
}
fn probe_daemon_active_artifacts() -> (Vec<PathBuf>, Option<&'static str>) {
let socket_path = match sqry_daemon::config::DaemonConfig::load() {
Ok(cfg) => cfg.socket_path(),
Err(_) => {
return (
Vec::new(),
Some("daemon config not loadable; daemon-locked check skipped"),
);
}
};
if !crate::commands::daemon::try_connect_sync(&socket_path).unwrap_or(false) {
return (
Vec::new(),
Some("sqryd is not running; daemon-locked check skipped"),
);
}
let rt = match tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
{
Ok(r) => r,
Err(_) => {
return (
Vec::new(),
Some("could not start tokio runtime to probe daemon; check skipped"),
);
}
};
rt.block_on(async {
let timeout = Duration::from_millis(DAEMON_ARTIFACTS_TIMEOUT_MS);
let probe = async {
let mut client = sqry_daemon_client::DaemonClient::connect(&socket_path).await?;
client.active_artifacts().await
};
match tokio::time::timeout(timeout, probe).await {
Ok(Ok(list)) => (list, None),
Ok(Err(_)) => (
Vec::new(),
Some("daemon/active-artifacts request failed; daemon-locked check skipped"),
),
Err(_) => (
Vec::new(),
Some("daemon/active-artifacts timed out at 250ms; daemon-locked check skipped"),
),
}
})
}
#[allow(dead_code)]
fn _assert_time_imports() {
let _ = SystemTime::now();
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn canonical(p: &Path) -> PathBuf {
p.canonicalize().unwrap()
}
fn make_layout(root: &Path) {
fs::create_dir_all(root.join(".sqry").join("graph")).unwrap();
fs::write(root.join(".sqry").join("graph").join("snapshot.sqry"), b"x").unwrap();
fs::create_dir_all(root.join(".sqry-cache")).unwrap();
fs::write(root.join(".sqry-cache").join("file"), b"x").unwrap();
fs::create_dir_all(root.join(".sqry-prof")).unwrap();
fs::write(root.join(".sqry-prof").join("file"), b"x").unwrap();
fs::write(root.join(".sqry-index"), b"legacy").unwrap();
fs::write(root.join(".sqry-index.user"), b"alias=foo").unwrap();
fs::write(root.join("Cargo.toml"), "[package]\n").unwrap();
}
fn dry_run(
root: &Path,
force: bool,
include_user_state: bool,
daemon_locked: &[PathBuf],
) -> (Vec<DiscoveredArtifact>, Vec<PathBuf>, Vec<SkippedArtifact>) {
let canonical_root = canonical(root);
let canonical_active = match discover_workspace_root(&canonical_root) {
WorkspaceRootDiscovery::GraphFound { root: r, .. } => {
Some(r.join(".sqry").join("graph"))
}
_ => None,
};
let (discovered, mut skipped) =
walk_artifacts(&canonical_root, canonical_active.as_deref(), daemon_locked).unwrap();
let mut planned = Vec::new();
for art in &discovered {
if art.is_canonical_active && !force {
skipped.push(SkippedArtifact {
path: art.path.clone(),
reason: SkipReason::CanonicalActive,
});
continue;
}
if art.is_daemon_locked && !force {
skipped.push(SkippedArtifact {
path: art.path.clone(),
reason: SkipReason::DaemonLocked,
});
continue;
}
if matches!(art.kind, ArtifactKind::WorkspaceRegistry) {
skipped.push(SkippedArtifact {
path: art.path.clone(),
reason: SkipReason::WorkspaceRegistry,
});
continue;
}
if matches!(art.kind, ArtifactKind::UserState) && !include_user_state {
skipped.push(SkippedArtifact {
path: art.path.clone(),
reason: SkipReason::UserState,
});
continue;
}
planned.push(art.path.clone());
}
(discovered, planned, skipped)
}
#[test]
fn dry_run_lists_stale() {
let tmp = TempDir::new().unwrap();
let root = tmp.path().join("proj");
fs::create_dir_all(&root).unwrap();
make_layout(&root);
let (discovered, planned, _skipped) = dry_run(&root, false, false, &[]);
assert_eq!(
discovered.len(),
5,
"expected 5 artifacts (sqry/graph-root, cache, prof, legacy, user-state), got {discovered:?}"
);
let active = canonical(&root).join(".sqry");
assert!(
!planned.iter().any(|p| p == &active),
"canonical active must be skipped without --force, planned={planned:?}"
);
let user = canonical(&root).join(".sqry-index.user");
assert!(
!planned.iter().any(|p| p == &user),
"user state must be skipped without --include-user-state, planned={planned:?}"
);
let must_be_planned = [
canonical(&root).join(".sqry-cache"),
canonical(&root).join(".sqry-prof"),
canonical(&root).join(".sqry-index"),
];
for p in must_be_planned {
assert!(
planned.contains(&p),
"{} must be in planned removals, planned={planned:?}",
p.display()
);
}
}
#[test]
fn apply_removes_planned_only() {
let tmp = TempDir::new().unwrap();
let root = tmp.path().join("proj");
fs::create_dir_all(&root).unwrap();
make_layout(&root);
let (_discovered, planned, _skipped) = dry_run(&root, false, false, &[]);
for p in &planned {
remove_path(p).unwrap();
}
assert!(
root.join(".sqry").join("graph").exists(),
"canonical active must survive"
);
assert!(
root.join(".sqry-index.user").exists(),
"user state must survive"
);
assert!(!root.join(".sqry-cache").exists());
assert!(!root.join(".sqry-prof").exists());
assert!(!root.join(".sqry-index").exists());
}
#[test]
fn apply_protects_canonical_without_force() {
let tmp = TempDir::new().unwrap();
let root = tmp.path().join("proj");
fs::create_dir_all(&root).unwrap();
make_layout(&root);
let (_discovered, planned, _skipped) = dry_run(&root, false, false, &[]);
let active = canonical(&root).join(".sqry");
assert!(
!planned.contains(&active),
"without --force the canonical active must not appear in planned removals"
);
}
#[test]
fn daemon_locked_protected() {
let tmp = TempDir::new().unwrap();
let root = tmp.path().join("proj");
fs::create_dir_all(&root).unwrap();
make_layout(&root);
let canonical_graph = canonical(&root).join(".sqry").join("graph");
let (discovered, planned, _skipped) =
dry_run(&root, false, false, std::slice::from_ref(&canonical_graph));
let saw_lock = discovered.iter().any(|a| {
a.is_daemon_locked && (a.path == canonical_graph || a.path.ends_with(".sqry"))
});
assert!(
saw_lock,
"daemon-locked detection must flag .sqry/ when its inner graph/ matches"
);
assert!(
!planned
.iter()
.any(|p| p == &canonical_graph || p.ends_with(".sqry")),
"daemon-locked artifact must be excluded from planned removals, got {planned:?}"
);
}
}