1use 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
26const BUILD_LOCK_FILENAME: &str = "build.lock";
33
34const GRAPH_SUBDIR: &str = ".sqry";
37const GRAPH_GRAPHDIR: &str = "graph";
38const SNAPSHOT_FILENAME: &str = "snapshot.sqry";
39
40const SNAPSHOT_MAGIC_PREFIX: &[u8] = b"SQRY_GRAPH_V";
48
49const SNAPSHOT_MIN_VALID_BYTES: usize = SNAPSHOT_MAGIC_PREFIX.len() + 2;
53
54pub 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(®istry_path).map_err(|err| {
69 anyhow!(
70 "Failed to load workspace at {}: {err}",
71 registry_path.display()
72 )
73 })?
74 } else {
75 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
112fn 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
124fn 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 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 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
183fn 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
194fn 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
218fn snapshot_modified_time(snapshot: &Path) -> Option<SystemTime> {
221 fs::metadata(snapshot).ok().and_then(|m| m.modified().ok())
222}
223
224fn 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
247fn 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
306fn 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 "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
381fn 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#[allow(
405 clippy::cast_possible_truncation,
406 clippy::cast_sign_loss,
407 clippy::similar_names
408)]
409fn 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 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 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 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 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 let temp = tempdir().unwrap();
504 let source_root = temp.path();
505 write_snapshot(source_root, b"SQRY"); 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 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 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 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}