Skip to main content

sqry_cli/commands/
workspace_status.rs

1//! `sqry workspace status <path>` — aggregate index-status reporting.
2//!
3//! Routes the user-supplied workspace path through [`LogicalWorkspace`]
4//! to obtain the canonical source-root list, then aggregates the
5//! per-source-root index status into a [`WorkspaceIndexStatus`]. The
6//! result is cached at
7//! `<workspace>/.sqry/workspace-cache/status.json` with a 60-second
8//! mtime-bound TTL — see [`sqry_core::workspace::cache`] for the
9//! durability contract.
10
11use anyhow::{Context, Result, anyhow};
12use std::fs;
13use std::io::Read;
14use std::path::{Path, PathBuf};
15use std::time::SystemTime;
16
17use sqry_core::workspace::{
18    LogicalWorkspace, SourceRootIndexState, SourceRootStatus, WorkspaceIndexStatus,
19    cache_path as workspace_cache_path, read_cache as read_status_cache,
20    write_cache as write_status_cache,
21};
22
23use crate::args::Cli;
24use crate::output::OutputStreams;
25
26/// Filename that signals an in-progress index build (presence ⇒
27/// `building`). The unified-graph build pipeline writes this lockfile
28/// while [Phase 1–4 + Pass 5](../../../../CLAUDE.md) is running. We
29/// inspect the file from outside the daemon, so a stale lock from a
30/// crashed `sqry index` will look like `building` until the next clean
31/// build — that mirrors the per-source-root contract today.
32const BUILD_LOCK_FILENAME: &str = "build.lock";
33
34/// Per the storage contract in §1.2 of the implementation plan, the
35/// canonical snapshot path is `<source_root>/.sqry/graph/snapshot.sqry`.
36const GRAPH_SUBDIR: &str = ".sqry";
37const GRAPH_GRAPHDIR: &str = "graph";
38const SNAPSHOT_FILENAME: &str = "snapshot.sqry";
39
40/// Stable magic-byte prefix shared by every supported snapshot version
41/// (`SQRY_GRAPH_V7` … `SQRY_GRAPH_V10`). See
42/// `sqry-core/src/graph/unified/persistence/format.rs`. We only check
43/// the family prefix here — the full version-aware integrity check
44/// happens inside `sqry-core`'s loader; the goal of this surface is to
45/// distinguish a healthy snapshot file from an obviously corrupt or
46/// truncated one for the `SourceRootIndexState::Error` bucket.
47const SNAPSHOT_MAGIC_PREFIX: &[u8] = b"SQRY_GRAPH_V";
48
49/// Minimum bytes we require before treating a snapshot as plausibly
50/// valid — long enough to hold the longest known prefix
51/// (`SQRY_GRAPH_V10` is 14 bytes) plus one trailing byte.
52const SNAPSHOT_MIN_VALID_BYTES: usize = SNAPSHOT_MAGIC_PREFIX.len() + 2;
53
54/// Run `sqry workspace status <path>`.
55///
56/// # Errors
57///
58/// Surfaces any error from path canonicalization, registry parsing, or
59/// cache I/O. Per-source-root failures (missing snapshot, unreadable
60/// metadata) are folded into the aggregate as `Missing` / `Error`
61/// entries rather than propagated.
62pub fn run(cli: &Cli, workspace: &str, json: bool, no_cache: bool) -> Result<()> {
63    let workspace_dir = canonicalize_existing(workspace)
64        .with_context(|| format!("Workspace path {workspace} not found"))?;
65    let registry_path = workspace_dir.join(".sqry-workspace");
66
67    let logical = if registry_path.exists() {
68        LogicalWorkspace::from_sqry_workspace(&registry_path).map_err(|err| {
69            anyhow!(
70                "Failed to load workspace at {}: {err}",
71                registry_path.display()
72            )
73        })?
74    } else {
75        // Fall back to a single-root workspace so `status` works even
76        // before `sqry workspace init` runs. The cache directory still
77        // lives under <workspace_dir>/.sqry/workspace-cache.
78        LogicalWorkspace::single_root(workspace_dir.clone()).map_err(|err| {
79            anyhow!(
80                "Failed to derive single-root workspace at {}: {err}",
81                workspace_dir.display()
82            )
83        })?
84    };
85
86    let status = if no_cache {
87        compute_and_persist(&workspace_dir, &logical)
88    } else {
89        match read_status_cache(&workspace_dir).with_context(|| {
90            format!(
91                "Failed to read aggregate status cache at {}",
92                workspace_cache_path(&workspace_dir).display()
93            )
94        })? {
95            Some(cached) => cached,
96            None => compute_and_persist(&workspace_dir, &logical),
97        }
98    };
99
100    let mut streams = OutputStreams::with_pager(cli.pager_config());
101    if json {
102        let payload = render_json(&workspace_dir, &logical, &status);
103        streams.write_result(&serde_json::to_string_pretty(&payload)?)?;
104    } else {
105        for line in render_text(&workspace_dir, &logical, &status) {
106            streams.write_result(&line)?;
107        }
108    }
109    streams.finish_checked()
110}
111
112/// Canonicalize an on-disk path or return a friendly error.
113fn canonicalize_existing(path: &str) -> Result<PathBuf> {
114    let candidate = PathBuf::from(path);
115    if candidate.exists() {
116        candidate
117            .canonicalize()
118            .with_context(|| format!("Failed to resolve path {path}"))
119    } else {
120        Err(anyhow!("Path '{path}' does not exist"))
121    }
122}
123
124/// Derive a per-source-root status from on-disk artefacts.
125fn compute_source_root_status(source_root: &Path) -> SourceRootStatus {
126    let graph_dir = source_root.join(GRAPH_SUBDIR).join(GRAPH_GRAPHDIR);
127    let snapshot = graph_dir.join(SNAPSHOT_FILENAME);
128    let lock = graph_dir.join(BUILD_LOCK_FILENAME);
129
130    // `building` wins over `missing` and `ok`: a rebuild may produce a
131    // fresh snapshot at any moment, so we surface the in-progress state
132    // even if the previous snapshot is still on disk.
133    if lock.exists() {
134        return SourceRootStatus {
135            path: source_root.to_path_buf(),
136            status: SourceRootIndexState::Building,
137            last_indexed_at: snapshot_modified_time(&snapshot),
138            symbol_count: None,
139            classpath_dir: probe_classpath_dir(source_root),
140        };
141    }
142
143    match fs::metadata(&snapshot) {
144        Ok(meta) => match snapshot_appears_valid(&snapshot) {
145            Ok(true) => {
146                let last_indexed_at = meta.modified().ok();
147                SourceRootStatus {
148                    path: source_root.to_path_buf(),
149                    status: SourceRootIndexState::Ok,
150                    last_indexed_at,
151                    symbol_count: None,
152                    classpath_dir: probe_classpath_dir(source_root),
153                }
154            }
155            // Truncated, corrupt, or unreadable payload — distinct
156            // from `Missing` (no file at all) and surfaced via the
157            // `Error` bucket so operators see the failure.
158            Ok(false) | Err(_) => SourceRootStatus {
159                path: source_root.to_path_buf(),
160                status: SourceRootIndexState::Error,
161                last_indexed_at: None,
162                symbol_count: None,
163                classpath_dir: probe_classpath_dir(source_root),
164            },
165        },
166        Err(err) if err.kind() == std::io::ErrorKind::NotFound => SourceRootStatus {
167            path: source_root.to_path_buf(),
168            status: SourceRootIndexState::Missing,
169            last_indexed_at: None,
170            symbol_count: None,
171            classpath_dir: probe_classpath_dir(source_root),
172        },
173        Err(_) => SourceRootStatus {
174            path: source_root.to_path_buf(),
175            status: SourceRootIndexState::Error,
176            last_indexed_at: None,
177            symbol_count: None,
178            classpath_dir: probe_classpath_dir(source_root),
179        },
180    }
181}
182
183/// STEP_11_4 — probe `<source_root>/.sqry/classpath/` for the JVM
184/// classpath directory and return its absolute path when present.
185/// Returns `None` when the directory is absent or the probe failed.
186fn probe_classpath_dir(source_root: &Path) -> Option<PathBuf> {
187    let probe = source_root.join(GRAPH_SUBDIR).join("classpath");
188    match fs::metadata(&probe) {
189        Ok(meta) if meta.is_dir() => Some(probe),
190        _ => None,
191    }
192}
193
194/// Lightweight integrity probe for `<source_root>/.sqry/graph/snapshot.sqry`.
195///
196/// Reads only the leading magic-byte header (≤16 bytes) and confirms
197/// the file starts with [`SNAPSHOT_MAGIC_PREFIX`]. Returns:
198///
199/// - `Ok(true)`  — file opens, is at least [`SNAPSHOT_MIN_VALID_BYTES`]
200///   long, and starts with the shared magic prefix.
201/// - `Ok(false)` — file opens but is too short or the prefix doesn't
202///   match (truncated / corrupt payload).
203/// - `Err(_)`    — open or read failed; the caller folds this into the
204///   `Error` bucket alongside `Ok(false)`.
205///
206/// This is intentionally a fast smoke test — full version-aware
207/// validation lives in `sqry-core`'s snapshot loader. The goal here is
208/// to distinguish "indexed and healthy enough to claim Ok" from
209/// "snapshot file present but unreadable garbage" without paying the
210/// cost of a full deserialise from the workspace-status surface.
211fn snapshot_appears_valid(snapshot: &Path) -> std::io::Result<bool> {
212    let mut buf = [0u8; 16];
213    let mut file = fs::File::open(snapshot)?;
214    let n = file.read(&mut buf)?;
215    Ok(n >= SNAPSHOT_MIN_VALID_BYTES && buf.starts_with(SNAPSHOT_MAGIC_PREFIX))
216}
217
218/// Read the snapshot file's mtime if it exists; used as `last_indexed_at`
219/// when a `Building` lockfile is also present.
220fn snapshot_modified_time(snapshot: &Path) -> Option<SystemTime> {
221    fs::metadata(snapshot).ok().and_then(|m| m.modified().ok())
222}
223
224/// Compute the aggregate and persist the cache.
225///
226/// Cache write failures are logged at warn level but never propagated:
227/// the user-visible `sqry workspace status` command must succeed even
228/// when the workspace lives on a read-only filesystem.
229fn compute_and_persist(workspace_dir: &Path, logical: &LogicalWorkspace) -> WorkspaceIndexStatus {
230    let entries: Vec<SourceRootStatus> = logical
231        .source_roots()
232        .iter()
233        .map(|sr| compute_source_root_status(&sr.path))
234        .collect();
235    let aggregate = WorkspaceIndexStatus::from_source_root_statuses(entries);
236
237    if let Err(err) = write_status_cache(workspace_dir, &aggregate) {
238        log::warn!(
239            "failed to persist workspace status cache at {}: {err}",
240            workspace_cache_path(workspace_dir).display()
241        );
242    }
243
244    aggregate
245}
246
247/// Build the human-readable text rendering as a flat line list.
248fn render_text(
249    workspace_dir: &Path,
250    logical: &LogicalWorkspace,
251    status: &WorkspaceIndexStatus,
252) -> Vec<String> {
253    let mut out = Vec::new();
254    out.push(format!("Workspace: {}", workspace_dir.display()));
255    out.push(format!(
256        "Workspace ID: {}  (full: {})",
257        logical.workspace_id().as_short_hex(),
258        logical.workspace_id().as_full_hex()
259    ));
260    out.push(format!(
261        "Project root mode: {}",
262        logical.project_root_mode()
263    ));
264    out.push(format!(
265        "Source roots: {} total / {} indexed / {} missing / {} building / {} error",
266        status.total(),
267        status.ok_count,
268        status.missing_count,
269        status.building_count,
270        status.error_count
271    ));
272    for entry in &status.source_root_statuses {
273        let glyph = match entry.status {
274            SourceRootIndexState::Ok => "ok",
275            SourceRootIndexState::Missing => "missing",
276            SourceRootIndexState::Building => "building",
277            SourceRootIndexState::Error => "error",
278        };
279        let last = entry
280            .last_indexed_at
281            .map_or_else(|| "never".to_string(), format_system_time);
282        out.push(format!(
283            "  [{glyph}] {}  (last indexed: {last})",
284            entry.path.display()
285        ));
286    }
287    out.push(format!(
288        "Member folders: {}",
289        logical.member_folders().len()
290    ));
291    for member in logical.member_folders() {
292        let reason = match member.reason {
293            sqry_core::workspace::MemberReason::OperationalFolder => "operational",
294            sqry_core::workspace::MemberReason::NonSourceFolder => "non-source",
295            sqry_core::workspace::MemberReason::NoLanguagePluginMatch => "no-language-plugin-match",
296        };
297        out.push(format!("  {}  (reason: {reason})", member.path.display()));
298    }
299    out.push(format!("Exclusions: {}", logical.exclusions().len()));
300    for excl in logical.exclusions() {
301        out.push(format!("  {}", excl.display()));
302    }
303    out
304}
305
306/// Build the `--json` payload as a `serde_json::Value` so the caller
307/// can re-pretty-print and so tests can assert against the structure.
308fn render_json(
309    workspace_dir: &Path,
310    logical: &LogicalWorkspace,
311    status: &WorkspaceIndexStatus,
312) -> serde_json::Value {
313    let source_roots: Vec<serde_json::Value> = status
314        .source_root_statuses
315        .iter()
316        .map(|entry| {
317            serde_json::json!({
318                "path": entry.path,
319                "status": index_state_str(entry.status),
320                "last_indexed_at": entry.last_indexed_at.map(format_system_time),
321                "symbol_count": entry.symbol_count,
322            })
323        })
324        .collect();
325    let member_folders: Vec<serde_json::Value> = logical
326        .member_folders()
327        .iter()
328        .map(|m| {
329            serde_json::json!({
330                "path": m.path,
331                "reason": member_reason_str(m.reason),
332            })
333        })
334        .collect();
335    let exclusions: Vec<serde_json::Value> = logical
336        .exclusions()
337        .iter()
338        .map(|p| serde_json::json!(p))
339        .collect();
340
341    serde_json::json!({
342        "workspace_path": workspace_dir,
343        "workspace_id_short": logical.workspace_id().as_short_hex(),
344        "workspace_id_full": logical.workspace_id().as_full_hex(),
345        "project_root_mode": logical.project_root_mode().as_str(),
346        "source_roots": source_roots,
347        "member_folders": member_folders,
348        "exclusions": exclusions,
349        "aggregate": {
350            "total": status.total(),
351            "ok_count": status.ok_count,
352            "missing_count": status.missing_count,
353            "building_count": status.building_count,
354            "error_count": status.error_count,
355            // Backwards-friendly aliases (the brief spells some keys
356            // both ways): emit both so JSON consumers tolerate either.
357            "indexed": status.ok_count,
358            "missing": status.missing_count,
359            "building": status.building_count,
360        },
361    })
362}
363
364fn index_state_str(state: SourceRootIndexState) -> &'static str {
365    match state {
366        SourceRootIndexState::Ok => "ok",
367        SourceRootIndexState::Missing => "missing",
368        SourceRootIndexState::Building => "building",
369        SourceRootIndexState::Error => "error",
370    }
371}
372
373fn member_reason_str(reason: sqry_core::workspace::MemberReason) -> &'static str {
374    match reason {
375        sqry_core::workspace::MemberReason::OperationalFolder => "operational",
376        sqry_core::workspace::MemberReason::NonSourceFolder => "non-source",
377        sqry_core::workspace::MemberReason::NoLanguagePluginMatch => "no-language-plugin-match",
378    }
379}
380
381/// Render a `SystemTime` as a stable RFC-3339 / ISO-8601 UTC string
382/// (`YYYY-MM-DDTHH:MM:SSZ`). We avoid pulling in `chrono` /
383/// `humantime` here so the workspace-status surface stays free of new
384/// transitive dependencies.
385fn format_system_time(t: SystemTime) -> String {
386    let secs = t
387        .duration_since(SystemTime::UNIX_EPOCH)
388        .map(|d| d.as_secs())
389        .unwrap_or(0);
390    let days_since_epoch = i64::try_from(secs / 86_400).unwrap_or(0);
391    let secs_of_day = secs % 86_400;
392    let (year, month, day) = civil_from_days(days_since_epoch);
393    let hour = secs_of_day / 3600;
394    let minute = (secs_of_day % 3600) / 60;
395    let second = secs_of_day % 60;
396    format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}Z")
397}
398
399/// Days-since-1970-01-01 → (year, month, day) using Howard Hinnant's
400/// proleptic-Gregorian formulae
401/// (<https://howardhinnant.github.io/date_algorithms.html#days_from_civil>).
402/// Deterministic; never panics for any value the caller can produce
403/// from a `SystemTime` since the UNIX epoch.
404#[allow(
405    clippy::cast_possible_truncation,
406    clippy::cast_sign_loss,
407    clippy::similar_names
408)]
409// `month` and `day` are bounded to 1..=31 / 1..=12 by construction,
410// so the `as u32` casts cannot truncate meaningful bits or lose sign.
411// The `yoe` / `y` / `year` triple are distinct intermediate values from
412// the cited algorithm and must keep their canonical names so the code
413// stays trivially auditable against the reference implementation.
414fn civil_from_days(days: i64) -> (i64, u32, u32) {
415    let z = days + 719_468;
416    let era = z.div_euclid(146_097);
417    let doe = z.rem_euclid(146_097);
418    let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
419    let y = yoe + era * 400;
420    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
421    let mp = (5 * doy + 2) / 153;
422    let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
423    let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
424    let year = if m <= 2 { y + 1 } else { y };
425    (year, m, d)
426}
427
428#[cfg(test)]
429mod tests {
430    //! Unit tests for the `compute_source_root_status` integrity probe.
431    //!
432    //! Codex iter1 `APPROVE_WITH_CHANGES` required that
433    //! `SourceRootIndexState::Error` actually distinguish a corrupt /
434    //! truncated `snapshot.sqry` from a healthy one. These tests
435    //! exercise the lightweight magic-byte check in
436    //! [`snapshot_appears_valid`] from each direction (healthy / too
437    //! short / wrong magic / missing).
438
439    use super::*;
440    use tempfile::tempdir;
441
442    fn write_snapshot(source_root: &Path, bytes: &[u8]) -> PathBuf {
443        let graph_dir = source_root.join(GRAPH_SUBDIR).join(GRAPH_GRAPHDIR);
444        std::fs::create_dir_all(&graph_dir).unwrap();
445        let snapshot = graph_dir.join(SNAPSHOT_FILENAME);
446        std::fs::write(&snapshot, bytes).unwrap();
447        snapshot
448    }
449
450    #[test]
451    fn compute_source_root_status_returns_ok_for_valid_magic() {
452        let temp = tempdir().unwrap();
453        let source_root = temp.path();
454        // Valid V10 magic + payload byte → should be `Ok`.
455        write_snapshot(source_root, b"SQRY_GRAPH_V10\0postcard-payload-bytes");
456        let status = compute_source_root_status(source_root);
457        assert_eq!(
458            status.status,
459            SourceRootIndexState::Ok,
460            "valid magic must yield Ok, got {:?}",
461            status.status
462        );
463        assert!(
464            status.last_indexed_at.is_some(),
465            "Ok must carry last_indexed_at"
466        );
467    }
468
469    #[test]
470    fn compute_source_root_status_returns_ok_for_v7_magic() {
471        // Confirms the family-prefix check covers all supported
472        // versions (V7 through V10), not just V10.
473        let temp = tempdir().unwrap();
474        let source_root = temp.path();
475        write_snapshot(source_root, b"SQRY_GRAPH_V7\0\0\0postcard-payload");
476        let status = compute_source_root_status(source_root);
477        assert_eq!(status.status, SourceRootIndexState::Ok);
478    }
479
480    #[test]
481    fn compute_source_root_status_returns_error_for_corrupt_snapshot() {
482        // File present, metadata readable, but the magic prefix is
483        // wrong → Error (not Ok, not Missing).
484        let temp = tempdir().unwrap();
485        let source_root = temp.path();
486        write_snapshot(source_root, b"\x00\x01\x02junk-payload-with-no-magic-bytes");
487        let status = compute_source_root_status(source_root);
488        assert_eq!(
489            status.status,
490            SourceRootIndexState::Error,
491            "corrupt snapshot must yield Error, got {:?}",
492            status.status
493        );
494        assert!(
495            status.last_indexed_at.is_none(),
496            "Error entries do not carry last_indexed_at"
497        );
498    }
499
500    #[test]
501    fn compute_source_root_status_returns_error_for_truncated_snapshot() {
502        // File too short to even hold the magic prefix → Error.
503        let temp = tempdir().unwrap();
504        let source_root = temp.path();
505        write_snapshot(source_root, b"SQRY"); // 4 bytes, far below minimum
506        let status = compute_source_root_status(source_root);
507        assert_eq!(status.status, SourceRootIndexState::Error);
508    }
509
510    #[test]
511    fn compute_source_root_status_returns_missing_when_absent() {
512        let temp = tempdir().unwrap();
513        // Don't create `.sqry/graph/snapshot.sqry` at all.
514        let status = compute_source_root_status(temp.path());
515        assert_eq!(status.status, SourceRootIndexState::Missing);
516    }
517
518    #[test]
519    fn compute_source_root_status_returns_building_when_lock_present() {
520        // Building wins over both Ok and Error/Missing per the
521        // pre-existing contract — make sure the new magic-byte check
522        // doesn't accidentally override that priority.
523        let temp = tempdir().unwrap();
524        let source_root = temp.path();
525        let graph_dir = source_root.join(GRAPH_SUBDIR).join(GRAPH_GRAPHDIR);
526        std::fs::create_dir_all(&graph_dir).unwrap();
527        std::fs::write(graph_dir.join(BUILD_LOCK_FILENAME), b"").unwrap();
528        // Even with a corrupt snapshot the lockfile must dominate.
529        std::fs::write(graph_dir.join(SNAPSHOT_FILENAME), b"junk").unwrap();
530        let status = compute_source_root_status(source_root);
531        assert_eq!(status.status, SourceRootIndexState::Building);
532    }
533}