use std::path::{Path, PathBuf};
use thiserror::Error;
#[derive(Debug, Error)]
pub enum GraphConfigError {
#[error("Config directory not found at {0}. Run `sqry config init` to create it.")]
NotInitialized(PathBuf),
#[error(
"Network filesystem detected at {0}. Config operations may be unreliable. Set config.durability.allow_network_filesystems=true to proceed."
)]
NetworkFilesystem(PathBuf),
#[error("IO error at {0}: {1}")]
IoError(PathBuf, #[source] std::io::Error),
#[error("Invalid path: {0}")]
InvalidPath(String),
}
#[cfg(target_os = "linux")]
const NFS_SUPER_MAGIC: i128 = 0x6969;
#[cfg(target_os = "linux")]
const SMB_SUPER_MAGIC: i128 = 0x517B;
#[cfg(target_os = "linux")]
const CIFS_MAGIC_NUMBER: i128 = 0xFF53_4D42;
#[cfg(target_os = "linux")]
const AFS_SUPER_MAGIC: i128 = 0x5346_414F;
#[cfg(target_os = "linux")]
const CODA_SUPER_MAGIC: i128 = 0x7375_7245;
pub type Result<T> = std::result::Result<T, GraphConfigError>;
#[derive(Debug, Clone)]
pub struct GraphConfigPaths {
project_root: PathBuf,
graph_dir_override: Option<PathBuf>,
}
impl GraphConfigPaths {
pub fn new<P: AsRef<Path>>(project_root: P) -> Result<Self> {
let project_root = project_root.as_ref();
if !project_root.exists() {
return Err(GraphConfigError::InvalidPath(format!(
"Project root does not exist: {}",
project_root.display()
)));
}
if !project_root.is_dir() {
return Err(GraphConfigError::InvalidPath(format!(
"Project root is not a directory: {}",
project_root.display()
)));
}
Ok(Self {
project_root: project_root.to_path_buf(),
graph_dir_override: None,
})
}
pub fn with_graph_dir<P: AsRef<Path>, G: AsRef<Path>>(
project_root: P,
graph_dir: G,
) -> Result<Self> {
let mut paths = Self::new(project_root)?;
paths.graph_dir_override = Some(graph_dir.as_ref().to_path_buf());
Ok(paths)
}
#[must_use]
pub fn graph_dir(&self) -> PathBuf {
self.graph_dir_override
.clone()
.unwrap_or_else(|| self.project_root.join(".sqry").join("graph"))
}
#[must_use]
pub fn config_dir(&self) -> PathBuf {
self.graph_dir().join("config")
}
#[must_use]
pub fn config_file(&self) -> PathBuf {
self.config_dir().join("config.json")
}
#[must_use]
pub fn previous_file(&self) -> PathBuf {
self.config_dir().join("config.json.previous")
}
#[must_use]
pub fn lock_file(&self) -> PathBuf {
self.config_dir().join("config.lock")
}
#[must_use]
pub fn corrupt_file(&self, timestamp: &str) -> PathBuf {
self.config_dir()
.join(format!("config.json.corrupt.{timestamp}"))
}
#[must_use]
pub fn config_dir_exists(&self) -> bool {
let config_dir = self.config_dir();
config_dir.exists() && config_dir.is_dir()
}
#[must_use]
pub fn config_file_exists(&self) -> bool {
self.config_file().exists()
}
pub fn is_network_filesystem(&self) -> Result<bool> {
let path = self.graph_dir();
#[cfg(target_os = "linux")]
{
Self::is_network_filesystem_linux(&path)
}
#[cfg(target_os = "macos")]
{
self.is_network_filesystem_macos(&path)
}
#[cfg(windows)]
{
self.is_network_filesystem_windows(&path)
}
#[cfg(not(any(target_os = "linux", target_os = "macos", windows)))]
{
log::debug!(
"Network filesystem detection not implemented for this platform. \
Assuming local filesystem at {}",
path.display()
);
Ok(false)
}
}
#[cfg(target_os = "linux")]
fn is_network_filesystem_linux(path: &Path) -> Result<bool> {
use std::ffi::CString;
let path_cstr = CString::new(path.to_string_lossy().as_bytes())
.map_err(|e| GraphConfigError::InvalidPath(e.to_string()))?;
let mut stat: libc::statfs = unsafe { std::mem::zeroed() };
let result = unsafe { libc::statfs(path_cstr.as_ptr(), &raw mut stat) };
if result != 0 {
let err = std::io::Error::last_os_error();
if err.kind() == std::io::ErrorKind::NotFound {
let mut current = path.parent();
while let Some(parent) = current {
if parent.exists() {
return Self::is_network_filesystem_linux(parent);
}
current = parent.parent();
}
}
return Err(GraphConfigError::IoError(path.to_path_buf(), err));
}
let fs_type = i128::from(stat.f_type);
let is_network = matches!(
fs_type,
NFS_SUPER_MAGIC
| SMB_SUPER_MAGIC
| CIFS_MAGIC_NUMBER
| AFS_SUPER_MAGIC
| CODA_SUPER_MAGIC
);
if is_network {
log::warn!(
"Network filesystem detected at {} (type: 0x{:X}). \
Config operations may be unreliable. Consider using a local filesystem.",
path.display(),
fs_type
);
}
Ok(is_network)
}
#[cfg(target_os = "macos")]
fn is_network_filesystem_macos(&self, path: &Path) -> Result<bool> {
use std::ffi::CString;
use std::mem::MaybeUninit;
const NETWORK_FS_TYPES: &[&str] = &[
"nfs", "smbfs", "afpfs", "webdav", "ftp", ];
let check_path = if path.exists() {
path.to_path_buf()
} else {
path.ancestors()
.find(|p| p.exists())
.map(Path::to_path_buf)
.unwrap_or_else(|| PathBuf::from("/"))
};
let c_path = CString::new(check_path.as_os_str().as_encoded_bytes())
.map_err(|e| GraphConfigError::InvalidPath(e.to_string()))?;
let mut stat: MaybeUninit<libc::statfs> = MaybeUninit::uninit();
let result = unsafe { libc::statfs(c_path.as_ptr(), stat.as_mut_ptr()) };
if result != 0 {
return Ok(false);
}
let stat = unsafe { stat.assume_init() };
let fs_type = unsafe {
std::ffi::CStr::from_ptr(stat.f_fstypename.as_ptr())
.to_string_lossy()
.to_lowercase()
};
let is_network = NETWORK_FS_TYPES.iter().any(|&t| fs_type.contains(t));
if is_network {
log::warn!(
"Network filesystem detected at {} (type: {}). \
Config operations may be unreliable. Consider using a local filesystem.",
path.display(),
fs_type
);
}
Ok(is_network)
}
#[cfg(windows)]
fn is_network_filesystem_windows(&self, path: &Path) -> Result<bool> {
use std::path::{Component, Prefix};
let first_component = path.components().next();
if let Some(Component::Prefix(prefix_component)) = first_component {
match prefix_component.kind() {
Prefix::UNC(_, _) | Prefix::VerbatimUNC(_, _) => {
log::warn!(
"Network filesystem detected at {} (UNC path). \
Config operations may be unreliable. Consider using a local filesystem.",
path.display()
);
return Ok(true);
}
Prefix::Disk(_) | Prefix::VerbatimDisk(_) => {
let root = format!("{}\\", prefix_component.as_os_str().to_string_lossy());
let wide_path: Vec<u16> =
root.encode_utf16().chain(std::iter::once(0)).collect();
let drive_type = unsafe {
windows_sys::Win32::Storage::FileSystem::GetDriveTypeW(wide_path.as_ptr())
};
let is_network = drive_type == 4;
if is_network {
log::warn!(
"Network filesystem detected at {} (mapped network drive). \
Config operations may be unreliable. Consider using a local filesystem.",
path.display()
);
}
return Ok(is_network);
}
Prefix::DeviceNS(_) | Prefix::Verbatim(_) => {
return Ok(false);
}
}
}
Ok(false)
}
pub fn validate(&self, allow_network_fs: bool) -> Result<()> {
if !allow_network_fs && self.is_network_filesystem()? {
return Err(GraphConfigError::NetworkFilesystem(self.graph_dir()));
}
Ok(())
}
}
#[derive(Debug)]
pub struct GraphConfigStore {
paths: GraphConfigPaths,
}
impl GraphConfigStore {
pub fn new<P: AsRef<Path>>(project_root: P) -> Result<Self> {
Ok(Self {
paths: GraphConfigPaths::new(project_root)?,
})
}
pub fn with_graph_dir<P: AsRef<Path>, G: AsRef<Path>>(
project_root: P,
graph_dir: G,
) -> Result<Self> {
Ok(Self {
paths: GraphConfigPaths::with_graph_dir(project_root, graph_dir)?,
})
}
#[must_use]
pub fn paths(&self) -> &GraphConfigPaths {
&self.paths
}
pub fn validate(&self, allow_network_fs: bool) -> Result<()> {
self.paths.validate(allow_network_fs)
}
#[must_use]
pub fn is_initialized(&self) -> bool {
self.paths.config_dir_exists()
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_new_with_valid_path() {
let temp = TempDir::new().unwrap();
let paths = GraphConfigPaths::new(temp.path()).unwrap();
assert_eq!(paths.project_root, temp.path());
}
#[test]
fn test_new_with_nonexistent_path() {
let result = GraphConfigPaths::new("/nonexistent/path/that/does/not/exist");
assert!(result.is_err());
assert!(matches!(result, Err(GraphConfigError::InvalidPath(_))));
}
#[test]
fn test_graph_dir_default() {
let temp = TempDir::new().unwrap();
let paths = GraphConfigPaths::new(temp.path()).unwrap();
let expected = temp.path().join(".sqry").join("graph");
assert_eq!(paths.graph_dir(), expected);
}
#[test]
fn test_graph_dir_override() {
let temp = TempDir::new().unwrap();
let override_dir = temp.path().join("custom-graph");
std::fs::create_dir_all(&override_dir).unwrap();
let paths = GraphConfigPaths::with_graph_dir(temp.path(), &override_dir).unwrap();
assert_eq!(paths.graph_dir(), override_dir);
}
#[test]
fn test_config_dir_path() {
let temp = TempDir::new().unwrap();
let paths = GraphConfigPaths::new(temp.path()).unwrap();
let expected = temp.path().join(".sqry").join("graph").join("config");
assert_eq!(paths.config_dir(), expected);
}
#[test]
fn test_config_file_path() {
let temp = TempDir::new().unwrap();
let paths = GraphConfigPaths::new(temp.path()).unwrap();
let expected = temp
.path()
.join(".sqry")
.join("graph")
.join("config")
.join("config.json");
assert_eq!(paths.config_file(), expected);
}
#[test]
fn test_previous_file_path() {
let temp = TempDir::new().unwrap();
let paths = GraphConfigPaths::new(temp.path()).unwrap();
let expected = temp
.path()
.join(".sqry")
.join("graph")
.join("config")
.join("config.json.previous");
assert_eq!(paths.previous_file(), expected);
}
#[test]
fn test_lock_file_path() {
let temp = TempDir::new().unwrap();
let paths = GraphConfigPaths::new(temp.path()).unwrap();
let expected = temp
.path()
.join(".sqry")
.join("graph")
.join("config")
.join("config.lock");
assert_eq!(paths.lock_file(), expected);
}
#[test]
fn test_corrupt_file_path() {
let temp = TempDir::new().unwrap();
let paths = GraphConfigPaths::new(temp.path()).unwrap();
let timestamp = "2025-12-15T21:30:00Z";
let expected = temp
.path()
.join(".sqry")
.join("graph")
.join("config")
.join(format!("config.json.corrupt.{timestamp}"));
assert_eq!(paths.corrupt_file(timestamp), expected);
}
#[test]
fn test_config_dir_exists_false_when_not_created() {
let temp = TempDir::new().unwrap();
let paths = GraphConfigPaths::new(temp.path()).unwrap();
assert!(!paths.config_dir_exists());
}
#[test]
fn test_config_dir_exists_true_when_created() {
let temp = TempDir::new().unwrap();
let paths = GraphConfigPaths::new(temp.path()).unwrap();
std::fs::create_dir_all(paths.config_dir()).unwrap();
assert!(paths.config_dir_exists());
}
#[test]
fn test_config_file_exists() {
let temp = TempDir::new().unwrap();
let paths = GraphConfigPaths::new(temp.path()).unwrap();
assert!(!paths.config_file_exists());
std::fs::create_dir_all(paths.config_dir()).unwrap();
std::fs::write(paths.config_file(), "{}").unwrap();
assert!(paths.config_file_exists());
}
#[test]
fn test_validate_missing_config_dir_ok_for_init() {
let temp = TempDir::new().unwrap();
let paths = GraphConfigPaths::new(temp.path()).unwrap();
let result = paths.validate(false);
assert!(result.is_ok());
}
#[test]
#[cfg(target_os = "linux")]
fn test_is_network_filesystem_on_local() {
let temp = TempDir::new().unwrap();
let paths = GraphConfigPaths::new(temp.path()).unwrap();
let result = paths.is_network_filesystem();
assert!(result.is_ok());
assert!(!result.unwrap());
}
#[test]
#[cfg(target_os = "linux")]
fn test_is_network_filesystem_with_nonexistent_path() {
let temp = TempDir::new().unwrap();
let paths = GraphConfigPaths::new(temp.path()).unwrap();
let result = paths.is_network_filesystem();
assert!(result.is_ok());
}
#[test]
#[cfg(target_os = "macos")]
fn test_is_network_filesystem_local_on_macos() {
let temp = TempDir::new().unwrap();
let paths = GraphConfigPaths::new(temp.path()).unwrap();
let result = paths.is_network_filesystem();
assert!(result.is_ok());
assert!(!result.unwrap());
}
#[test]
#[cfg(target_os = "macos")]
fn test_is_network_filesystem_nonexistent_path_macos() {
let temp = TempDir::new().unwrap();
let nonexistent = temp.path().join("does").join("not").join("exist");
let paths = GraphConfigPaths::with_graph_dir(temp.path(), &nonexistent).unwrap();
let result = paths.is_network_filesystem();
assert!(result.is_ok());
assert!(!result.unwrap());
}
#[test]
#[cfg(windows)]
fn test_is_network_filesystem_local_on_windows() {
let temp = TempDir::new().unwrap();
let paths = GraphConfigPaths::new(temp.path()).unwrap();
let result = paths.is_network_filesystem();
assert!(result.is_ok());
assert!(!result.unwrap());
}
#[test]
#[cfg(windows)]
fn test_is_network_filesystem_unc_path() {
let temp = TempDir::new().unwrap();
let unc_path = PathBuf::from(r"\\server\share\project\.sqry\graph");
let paths = GraphConfigPaths {
project_root: temp.path().to_path_buf(),
graph_dir_override: Some(unc_path),
};
let result = paths.is_network_filesystem();
assert!(result.is_ok());
assert!(result.unwrap());
}
#[test]
fn test_store_new() {
let temp = TempDir::new().unwrap();
let store = GraphConfigStore::new(temp.path()).unwrap();
assert_eq!(store.paths.project_root, temp.path());
}
#[test]
fn test_store_with_graph_dir() {
let temp = TempDir::new().unwrap();
let override_dir = temp.path().join("custom");
std::fs::create_dir_all(&override_dir).unwrap();
let store = GraphConfigStore::with_graph_dir(temp.path(), &override_dir).unwrap();
assert_eq!(store.paths.graph_dir(), override_dir);
}
#[test]
fn test_store_is_initialized() {
let temp = TempDir::new().unwrap();
let store = GraphConfigStore::new(temp.path()).unwrap();
assert!(!store.is_initialized());
std::fs::create_dir_all(store.paths.config_dir()).unwrap();
assert!(store.is_initialized());
}
#[test]
fn test_store_validate() {
let temp = TempDir::new().unwrap();
let store = GraphConfigStore::new(temp.path()).unwrap();
let result = store.validate(false);
assert!(result.is_ok());
}
#[test]
fn test_paths_accessor() {
let temp = TempDir::new().unwrap();
let store = GraphConfigStore::new(temp.path()).unwrap();
let paths = store.paths();
assert_eq!(paths.project_root, temp.path());
}
}