use anyhow::{Context, Result, anyhow};
use std::fs;
use std::io::Read;
use std::path::{Path, PathBuf};
use std::time::SystemTime;
use sqry_core::workspace::{
LogicalWorkspace, SourceRootIndexState, SourceRootStatus, WorkspaceIndexStatus,
cache_path as workspace_cache_path, read_cache as read_status_cache,
write_cache as write_status_cache,
};
use crate::args::Cli;
use crate::output::OutputStreams;
const BUILD_LOCK_FILENAME: &str = "build.lock";
const GRAPH_SUBDIR: &str = ".sqry";
const GRAPH_GRAPHDIR: &str = "graph";
const SNAPSHOT_FILENAME: &str = "snapshot.sqry";
const SNAPSHOT_MAGIC_PREFIX: &[u8] = b"SQRY_GRAPH_V";
const SNAPSHOT_MIN_VALID_BYTES: usize = SNAPSHOT_MAGIC_PREFIX.len() + 2;
pub fn run(cli: &Cli, workspace: &str, json: bool, no_cache: bool) -> Result<()> {
let workspace_dir = canonicalize_existing(workspace)
.with_context(|| format!("Workspace path {workspace} not found"))?;
let registry_path = workspace_dir.join(".sqry-workspace");
let logical = if registry_path.exists() {
LogicalWorkspace::from_sqry_workspace(®istry_path).map_err(|err| {
anyhow!(
"Failed to load workspace at {}: {err}",
registry_path.display()
)
})?
} else {
LogicalWorkspace::single_root(workspace_dir.clone()).map_err(|err| {
anyhow!(
"Failed to derive single-root workspace at {}: {err}",
workspace_dir.display()
)
})?
};
let status = if no_cache {
compute_and_persist(&workspace_dir, &logical)
} else {
match read_status_cache(&workspace_dir).with_context(|| {
format!(
"Failed to read aggregate status cache at {}",
workspace_cache_path(&workspace_dir).display()
)
})? {
Some(cached) => cached,
None => compute_and_persist(&workspace_dir, &logical),
}
};
let mut streams = OutputStreams::with_pager(cli.pager_config());
if json {
let payload = render_json(&workspace_dir, &logical, &status);
streams.write_result(&serde_json::to_string_pretty(&payload)?)?;
} else {
for line in render_text(&workspace_dir, &logical, &status) {
streams.write_result(&line)?;
}
}
streams.finish_checked()
}
fn canonicalize_existing(path: &str) -> Result<PathBuf> {
let candidate = PathBuf::from(path);
if candidate.exists() {
candidate
.canonicalize()
.with_context(|| format!("Failed to resolve path {path}"))
} else {
Err(anyhow!("Path '{path}' does not exist"))
}
}
fn compute_source_root_status(source_root: &Path) -> SourceRootStatus {
let graph_dir = source_root.join(GRAPH_SUBDIR).join(GRAPH_GRAPHDIR);
let snapshot = graph_dir.join(SNAPSHOT_FILENAME);
let lock = graph_dir.join(BUILD_LOCK_FILENAME);
if lock.exists() {
return SourceRootStatus {
path: source_root.to_path_buf(),
status: SourceRootIndexState::Building,
last_indexed_at: snapshot_modified_time(&snapshot),
symbol_count: None,
classpath_dir: probe_classpath_dir(source_root),
};
}
match fs::metadata(&snapshot) {
Ok(meta) => match snapshot_appears_valid(&snapshot) {
Ok(true) => {
let last_indexed_at = meta.modified().ok();
SourceRootStatus {
path: source_root.to_path_buf(),
status: SourceRootIndexState::Ok,
last_indexed_at,
symbol_count: None,
classpath_dir: probe_classpath_dir(source_root),
}
}
Ok(false) | Err(_) => SourceRootStatus {
path: source_root.to_path_buf(),
status: SourceRootIndexState::Error,
last_indexed_at: None,
symbol_count: None,
classpath_dir: probe_classpath_dir(source_root),
},
},
Err(err) if err.kind() == std::io::ErrorKind::NotFound => SourceRootStatus {
path: source_root.to_path_buf(),
status: SourceRootIndexState::Missing,
last_indexed_at: None,
symbol_count: None,
classpath_dir: probe_classpath_dir(source_root),
},
Err(_) => SourceRootStatus {
path: source_root.to_path_buf(),
status: SourceRootIndexState::Error,
last_indexed_at: None,
symbol_count: None,
classpath_dir: probe_classpath_dir(source_root),
},
}
}
fn probe_classpath_dir(source_root: &Path) -> Option<PathBuf> {
let probe = source_root.join(GRAPH_SUBDIR).join("classpath");
match fs::metadata(&probe) {
Ok(meta) if meta.is_dir() => Some(probe),
_ => None,
}
}
fn snapshot_appears_valid(snapshot: &Path) -> std::io::Result<bool> {
let mut buf = [0u8; 16];
let mut file = fs::File::open(snapshot)?;
let n = file.read(&mut buf)?;
Ok(n >= SNAPSHOT_MIN_VALID_BYTES && buf.starts_with(SNAPSHOT_MAGIC_PREFIX))
}
fn snapshot_modified_time(snapshot: &Path) -> Option<SystemTime> {
fs::metadata(snapshot).ok().and_then(|m| m.modified().ok())
}
fn compute_and_persist(workspace_dir: &Path, logical: &LogicalWorkspace) -> WorkspaceIndexStatus {
let entries: Vec<SourceRootStatus> = logical
.source_roots()
.iter()
.map(|sr| compute_source_root_status(&sr.path))
.collect();
let aggregate = WorkspaceIndexStatus::from_source_root_statuses(entries);
if let Err(err) = write_status_cache(workspace_dir, &aggregate) {
log::warn!(
"failed to persist workspace status cache at {}: {err}",
workspace_cache_path(workspace_dir).display()
);
}
aggregate
}
fn render_text(
workspace_dir: &Path,
logical: &LogicalWorkspace,
status: &WorkspaceIndexStatus,
) -> Vec<String> {
let mut out = Vec::new();
out.push(format!("Workspace: {}", workspace_dir.display()));
out.push(format!(
"Workspace ID: {} (full: {})",
logical.workspace_id().as_short_hex(),
logical.workspace_id().as_full_hex()
));
out.push(format!(
"Project root mode: {}",
logical.project_root_mode()
));
out.push(format!(
"Source roots: {} total / {} indexed / {} missing / {} building / {} error",
status.total(),
status.ok_count,
status.missing_count,
status.building_count,
status.error_count
));
for entry in &status.source_root_statuses {
let glyph = match entry.status {
SourceRootIndexState::Ok => "ok",
SourceRootIndexState::Missing => "missing",
SourceRootIndexState::Building => "building",
SourceRootIndexState::Error => "error",
};
let last = entry
.last_indexed_at
.map_or_else(|| "never".to_string(), format_system_time);
out.push(format!(
" [{glyph}] {} (last indexed: {last})",
entry.path.display()
));
}
out.push(format!(
"Member folders: {}",
logical.member_folders().len()
));
for member in logical.member_folders() {
let reason = match member.reason {
sqry_core::workspace::MemberReason::OperationalFolder => "operational",
sqry_core::workspace::MemberReason::NonSourceFolder => "non-source",
sqry_core::workspace::MemberReason::NoLanguagePluginMatch => "no-language-plugin-match",
};
out.push(format!(" {} (reason: {reason})", member.path.display()));
}
out.push(format!("Exclusions: {}", logical.exclusions().len()));
for excl in logical.exclusions() {
out.push(format!(" {}", excl.display()));
}
out
}
fn render_json(
workspace_dir: &Path,
logical: &LogicalWorkspace,
status: &WorkspaceIndexStatus,
) -> serde_json::Value {
let source_roots: Vec<serde_json::Value> = status
.source_root_statuses
.iter()
.map(|entry| {
serde_json::json!({
"path": entry.path,
"status": index_state_str(entry.status),
"last_indexed_at": entry.last_indexed_at.map(format_system_time),
"symbol_count": entry.symbol_count,
})
})
.collect();
let member_folders: Vec<serde_json::Value> = logical
.member_folders()
.iter()
.map(|m| {
serde_json::json!({
"path": m.path,
"reason": member_reason_str(m.reason),
})
})
.collect();
let exclusions: Vec<serde_json::Value> = logical
.exclusions()
.iter()
.map(|p| serde_json::json!(p))
.collect();
serde_json::json!({
"workspace_path": workspace_dir,
"workspace_id_short": logical.workspace_id().as_short_hex(),
"workspace_id_full": logical.workspace_id().as_full_hex(),
"project_root_mode": logical.project_root_mode().as_str(),
"source_roots": source_roots,
"member_folders": member_folders,
"exclusions": exclusions,
"aggregate": {
"total": status.total(),
"ok_count": status.ok_count,
"missing_count": status.missing_count,
"building_count": status.building_count,
"error_count": status.error_count,
"indexed": status.ok_count,
"missing": status.missing_count,
"building": status.building_count,
},
})
}
fn index_state_str(state: SourceRootIndexState) -> &'static str {
match state {
SourceRootIndexState::Ok => "ok",
SourceRootIndexState::Missing => "missing",
SourceRootIndexState::Building => "building",
SourceRootIndexState::Error => "error",
}
}
fn member_reason_str(reason: sqry_core::workspace::MemberReason) -> &'static str {
match reason {
sqry_core::workspace::MemberReason::OperationalFolder => "operational",
sqry_core::workspace::MemberReason::NonSourceFolder => "non-source",
sqry_core::workspace::MemberReason::NoLanguagePluginMatch => "no-language-plugin-match",
}
}
fn format_system_time(t: SystemTime) -> String {
let secs = t
.duration_since(SystemTime::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let days_since_epoch = i64::try_from(secs / 86_400).unwrap_or(0);
let secs_of_day = secs % 86_400;
let (year, month, day) = civil_from_days(days_since_epoch);
let hour = secs_of_day / 3600;
let minute = (secs_of_day % 3600) / 60;
let second = secs_of_day % 60;
format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}Z")
}
#[allow(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::similar_names
)]
fn civil_from_days(days: i64) -> (i64, u32, u32) {
let z = days + 719_468;
let era = z.div_euclid(146_097);
let doe = z.rem_euclid(146_097);
let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
let year = if m <= 2 { y + 1 } else { y };
(year, m, d)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
fn write_snapshot(source_root: &Path, bytes: &[u8]) -> PathBuf {
let graph_dir = source_root.join(GRAPH_SUBDIR).join(GRAPH_GRAPHDIR);
std::fs::create_dir_all(&graph_dir).unwrap();
let snapshot = graph_dir.join(SNAPSHOT_FILENAME);
std::fs::write(&snapshot, bytes).unwrap();
snapshot
}
#[test]
fn compute_source_root_status_returns_ok_for_valid_magic() {
let temp = tempdir().unwrap();
let source_root = temp.path();
write_snapshot(source_root, b"SQRY_GRAPH_V10\0postcard-payload-bytes");
let status = compute_source_root_status(source_root);
assert_eq!(
status.status,
SourceRootIndexState::Ok,
"valid magic must yield Ok, got {:?}",
status.status
);
assert!(
status.last_indexed_at.is_some(),
"Ok must carry last_indexed_at"
);
}
#[test]
fn compute_source_root_status_returns_ok_for_v7_magic() {
let temp = tempdir().unwrap();
let source_root = temp.path();
write_snapshot(source_root, b"SQRY_GRAPH_V7\0\0\0postcard-payload");
let status = compute_source_root_status(source_root);
assert_eq!(status.status, SourceRootIndexState::Ok);
}
#[test]
fn compute_source_root_status_returns_error_for_corrupt_snapshot() {
let temp = tempdir().unwrap();
let source_root = temp.path();
write_snapshot(source_root, b"\x00\x01\x02junk-payload-with-no-magic-bytes");
let status = compute_source_root_status(source_root);
assert_eq!(
status.status,
SourceRootIndexState::Error,
"corrupt snapshot must yield Error, got {:?}",
status.status
);
assert!(
status.last_indexed_at.is_none(),
"Error entries do not carry last_indexed_at"
);
}
#[test]
fn compute_source_root_status_returns_error_for_truncated_snapshot() {
let temp = tempdir().unwrap();
let source_root = temp.path();
write_snapshot(source_root, b"SQRY"); let status = compute_source_root_status(source_root);
assert_eq!(status.status, SourceRootIndexState::Error);
}
#[test]
fn compute_source_root_status_returns_missing_when_absent() {
let temp = tempdir().unwrap();
let status = compute_source_root_status(temp.path());
assert_eq!(status.status, SourceRootIndexState::Missing);
}
#[test]
fn compute_source_root_status_returns_building_when_lock_present() {
let temp = tempdir().unwrap();
let source_root = temp.path();
let graph_dir = source_root.join(GRAPH_SUBDIR).join(GRAPH_GRAPHDIR);
std::fs::create_dir_all(&graph_dir).unwrap();
std::fs::write(graph_dir.join(BUILD_LOCK_FILENAME), b"").unwrap();
std::fs::write(graph_dir.join(SNAPSHOT_FILENAME), b"junk").unwrap();
let status = compute_source_root_status(source_root);
assert_eq!(status.status, SourceRootIndexState::Building);
}
}