use anyhow::{Context, Result, bail};
use sqry_core::config::WorkspaceConfig;
use std::collections::HashSet;
use std::env;
use std::path::{Path, PathBuf};
static DISCOVERY_CACHE: parking_lot::Mutex<Option<lru::LruCache<String, PathBuf>>> =
parking_lot::Mutex::new(None);
pub fn init_discovery_cache(capacity: std::num::NonZeroUsize) {
let mut cache = DISCOVERY_CACHE.lock();
if cache.is_none() {
tracing::info!(capacity = capacity.get(), "Initializing discovery cache");
*cache = Some(lru::LruCache::new(capacity));
}
}
#[allow(dead_code)]
fn normalize_discovery_key(path: &str) -> String {
#[cfg(windows)]
{
path.to_lowercase().replace('/', "\\")
}
#[cfg(target_os = "macos")]
{
match is_case_sensitive_macos(path) {
Ok(false) => path.to_lowercase(), Ok(true) | Err(_) => path.to_string(), }
}
#[cfg(not(any(windows, target_os = "macos")))]
{
path.to_string() }
}
#[cfg(target_os = "macos")]
fn is_case_sensitive_macos(path: &str) -> Result<bool> {
use std::ffi::CString;
let probe_path = if Path::new(path).exists() {
path.to_string()
} else {
Path::new(path)
.parent()
.and_then(|p| p.to_str())
.unwrap_or("/") .to_string()
};
let c_path = CString::new(probe_path.as_bytes())?;
let result = unsafe { libc::pathconf(c_path.as_ptr(), libc::_PC_CASE_SENSITIVE) };
match result {
-1 => {
let errno = std::io::Error::last_os_error().raw_os_error().unwrap_or(0);
if errno == 0 {
tracing::debug!(path, "pathconf indeterminate, assuming case-sensitive");
Ok(true)
} else {
bail!(
"pathconf(_PC_CASE_SENSITIVE) failed for {}: {}",
probe_path,
std::io::Error::last_os_error()
)
}
}
0 => Ok(false), _ => Ok(true), }
}
pub fn resolve_workspace_path(explicit_path: &str) -> Result<PathBuf> {
let key = normalize_discovery_key(explicit_path);
{
let mut cache = DISCOVERY_CACHE.lock();
let lru = cache
.as_mut()
.context("Discovery cache not initialized - call init_discovery_cache() first")?;
if let Some(cached) = lru.get(&key) {
tracing::debug!(
path = explicit_path,
cached = %cached.display(),
"Discovery cache hit"
);
return Ok(cached.clone());
}
}
tracing::debug!(path = explicit_path, "Discovery cache miss, resolving");
let path_buf = PathBuf::from(explicit_path);
let resolver = WorkspaceResolver::new(Some(path_buf));
let resolved_path = resolver.resolve()?;
{
let mut cache = DISCOVERY_CACHE.lock();
if let Some(lru) = cache.as_mut() {
lru.put(key, resolved_path.clone());
tracing::debug!(
path = explicit_path,
resolved = %resolved_path.display(),
cache_size = lru.len(),
"Discovery result cached"
);
}
}
Ok(resolved_path)
}
pub struct WorkspaceResolver {
explicit_root: Option<PathBuf>,
}
impl WorkspaceResolver {
pub fn new(explicit_root: Option<PathBuf>) -> Self {
Self { explicit_root }
}
pub fn resolve(&self) -> Result<PathBuf> {
if let Some(root) = &self.explicit_root {
tracing::info!("Using explicit workspace_root: {:?}", root);
return self.validate_and_canonicalize(root);
}
if let Ok(root) = env::var("SQRY_MCP_WORKSPACE_ROOT") {
tracing::info!("Using SQRY_MCP_WORKSPACE_ROOT env var: {}", root);
let path = PathBuf::from(root);
return self.validate_and_canonicalize(&path);
}
if let Ok(root) = env::var("SQRY_WORKSPACE_ROOT") {
tracing::info!("Using SQRY_WORKSPACE_ROOT env var (legacy): {}", root);
let path = PathBuf::from(root);
return self.validate_and_canonicalize(&path);
}
tracing::info!("Using workspace discovery from CWD");
let cwd = env::current_dir()?;
self.discover_workspace(&cwd)
}
fn validate_and_canonicalize(&self, root: &Path) -> Result<PathBuf> {
let canonical = root.canonicalize()?;
self.validate_workspace(&canonical)?;
Ok(canonical)
}
#[allow(clippy::unused_self)] fn validate_workspace(&self, root: &Path) -> Result<()> {
if !root.is_dir() {
bail!(
"Not a valid sqry workspace: {} (not a directory)",
root.display()
);
}
Ok(())
}
#[allow(clippy::unused_self)] fn discover_workspace(&self, start: &Path) -> Result<PathBuf> {
let config = WorkspaceConfig::load_or_default()?;
let max_depth = config.effective_discovery_depth()?;
let mut visited = HashSet::new();
let mut current = start.canonicalize()?;
for _ in 0..max_depth {
if !visited.insert(current.clone()) {
bail!("Symlink loop detected at {}", current.display());
}
if current.join(".sqry/graph").exists() {
return Ok(current);
}
current = match current.parent() {
Some(p) => p.canonicalize()?,
None => bail!("No .sqry workspace found in parent directories"),
};
}
bail!("Workspace discovery exceeded depth limit ({max_depth})")
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
#[test]
fn test_explicit_parameter_priority() {
let temp = TempDir::new().unwrap();
let workspace = temp.path();
fs::create_dir_all(workspace.join(".sqry/graph")).unwrap();
let resolver = WorkspaceResolver::new(Some(workspace.to_path_buf()));
let result = resolver.resolve();
assert!(result.is_ok());
}
#[test]
fn test_directory_without_graph_accepted() {
let temp = TempDir::new().unwrap();
let workspace = temp.path();
let resolver = WorkspaceResolver::new(Some(workspace.to_path_buf()));
let result = resolver.resolve();
assert!(
result.is_ok(),
"Directory without .sqry/graph should be accepted"
);
}
#[test]
#[serial_test::serial(workspace_env)]
fn test_discovery_from_subdirectory() {
let temp = TempDir::new().unwrap();
let workspace = temp.path();
let subdir = workspace.join("src/deep/nested");
fs::create_dir_all(workspace.join(".sqry/graph")).unwrap();
fs::create_dir_all(&subdir).unwrap();
let old_cwd = env::current_dir().unwrap();
env::set_current_dir(&subdir).unwrap();
let resolver = WorkspaceResolver::new(None);
let result = resolver.resolve();
env::set_current_dir(old_cwd).unwrap();
assert!(result.is_ok());
assert_eq!(
result.unwrap().canonicalize().unwrap(),
workspace.canonicalize().unwrap()
);
}
#[test]
#[serial_test::serial(workspace_env)]
#[ignore = "Symlink loop behavior is platform-dependent and may resolve differently"]
fn test_symlink_loop_detected() {
#[cfg(unix)]
{
use std::os::unix::fs::symlink;
let temp = TempDir::new().unwrap();
let dir_a = temp.path().join("a");
let dir_b = temp.path().join("b");
fs::create_dir(&dir_a).unwrap();
fs::create_dir(&dir_b).unwrap();
symlink(&dir_b, dir_a.join("next")).unwrap();
symlink(&dir_a, dir_b.join("next")).unwrap();
let old_cwd = env::current_dir().unwrap();
env::set_current_dir(&dir_a).unwrap();
let resolver = WorkspaceResolver::new(None);
let result = resolver.resolve();
env::set_current_dir(old_cwd).unwrap();
assert!(result.is_err());
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("Symlink loop") || err_msg.contains("No .sqry workspace found")
);
}
}
#[test]
#[serial_test::serial(workspace_env)]
fn test_depth_limit_enforced() {
let temp = TempDir::new().unwrap();
let mut deep_path = temp.path().to_path_buf();
for i in 0..30 {
deep_path = deep_path.join(format!("d{i}"));
}
fs::create_dir_all(&deep_path).unwrap();
let old_cwd = env::current_dir().unwrap();
env::set_current_dir(&deep_path).unwrap();
let resolver = WorkspaceResolver::new(None);
let result = resolver.resolve();
env::set_current_dir(old_cwd).unwrap();
assert!(result.is_err());
let err_msg = result.unwrap_err().to_string();
assert!(err_msg.contains("depth limit") || err_msg.contains("No .sqry workspace found"));
}
#[test]
#[serial_test::serial(workspace_env)]
fn test_env_var_resolution() {
let temp = TempDir::new().unwrap();
let workspace = temp.path();
fs::create_dir_all(workspace.join(".sqry/graph")).unwrap();
unsafe {
env::set_var("SQRY_WORKSPACE_ROOT", workspace);
}
let resolver = WorkspaceResolver::new(None);
let result = resolver.resolve();
unsafe {
env::remove_var("SQRY_WORKSPACE_ROOT");
}
assert!(result.is_ok());
assert_eq!(
result.unwrap().canonicalize().unwrap(),
workspace.canonicalize().unwrap()
);
}
}
#[cfg(test)]
mod discovery_cache_tests {
use super::*;
use std::fs;
use tempfile::TempDir;
fn reset_discovery_cache() {
let mut cache = DISCOVERY_CACHE.lock();
*cache = None;
}
#[test]
#[serial_test::serial(discovery_cache)]
fn test_discovery_cache_requires_initialization() {
reset_discovery_cache();
let result = resolve_workspace_path("/tmp/test");
match result {
Err(e) => assert!(e.to_string().contains("not initialized")),
Ok(_) => panic!("Expected error, got success"),
}
}
#[test]
#[serial_test::serial(discovery_cache)]
fn test_discovery_cache_normalization() {
init_discovery_cache(std::num::NonZeroUsize::new(100).unwrap());
let temp = TempDir::new().unwrap();
let workspace = temp.path();
fs::create_dir_all(workspace.join(".sqry/graph")).unwrap();
let path_str = workspace.to_str().unwrap();
let result1 = resolve_workspace_path(path_str);
assert!(result1.is_ok());
let result2 = resolve_workspace_path(path_str);
assert!(result2.is_ok());
assert_eq!(
result1.unwrap().canonicalize().unwrap(),
result2.unwrap().canonicalize().unwrap()
);
}
#[test]
#[cfg(target_os = "macos")]
fn test_macos_pathconf_error_handling() {
let result = is_case_sensitive_macos("/nonexistent/path/that/does/not/exist/very/deep");
match result {
Ok(is_sensitive) => {
assert!(is_sensitive);
}
Err(_) => {
}
}
}
#[test]
#[cfg(target_os = "macos")]
fn test_macos_pathconf_root() {
let result = is_case_sensitive_macos("/");
assert!(result.is_ok());
}
#[test]
fn test_normalize_discovery_key_idempotent() {
let path = "/tmp/test";
let key1 = normalize_discovery_key(path);
let key2 = normalize_discovery_key(&key1);
#[cfg(not(windows))]
assert_eq!(key1, key2);
#[cfg(windows)]
{
assert_eq!(key1.to_lowercase(), key2);
}
}
}