sqry_core/config/
graph_config_store.rs1use std::path::{Path, PathBuf};
21use thiserror::Error;
22
23#[derive(Debug, Error)]
25pub enum GraphConfigError {
26 #[error("Config directory not found at {0}. Run `sqry config init` to create it.")]
28 NotInitialized(PathBuf),
29
30 #[error(
32 "Network filesystem detected at {0}. Config operations may be unreliable. Set config.durability.allow_network_filesystems=true to proceed."
33 )]
34 NetworkFilesystem(PathBuf),
35
36 #[error("IO error at {0}: {1}")]
38 IoError(PathBuf, #[source] std::io::Error),
39
40 #[error("Invalid path: {0}")]
42 InvalidPath(String),
43}
44
45#[cfg(target_os = "linux")]
46const NFS_SUPER_MAGIC: i128 = 0x6969;
47#[cfg(target_os = "linux")]
48const SMB_SUPER_MAGIC: i128 = 0x517B;
49#[cfg(target_os = "linux")]
50const CIFS_MAGIC_NUMBER: i128 = 0xFF53_4D42;
51#[cfg(target_os = "linux")]
52const AFS_SUPER_MAGIC: i128 = 0x5346_414F;
53#[cfg(target_os = "linux")]
54const CODA_SUPER_MAGIC: i128 = 0x7375_7245;
55
56pub type Result<T> = std::result::Result<T, GraphConfigError>;
58
59#[derive(Debug, Clone)]
74pub struct GraphConfigPaths {
75 project_root: PathBuf,
77 graph_dir_override: Option<PathBuf>,
79}
80
81impl GraphConfigPaths {
82 pub fn new<P: AsRef<Path>>(project_root: P) -> Result<Self> {
92 let project_root = project_root.as_ref();
93
94 if !project_root.exists() {
96 return Err(GraphConfigError::InvalidPath(format!(
97 "Project root does not exist: {}",
98 project_root.display()
99 )));
100 }
101
102 if !project_root.is_dir() {
103 return Err(GraphConfigError::InvalidPath(format!(
104 "Project root is not a directory: {}",
105 project_root.display()
106 )));
107 }
108
109 Ok(Self {
110 project_root: project_root.to_path_buf(),
111 graph_dir_override: None,
112 })
113 }
114
115 pub fn with_graph_dir<P: AsRef<Path>, G: AsRef<Path>>(
129 project_root: P,
130 graph_dir: G,
131 ) -> Result<Self> {
132 let mut paths = Self::new(project_root)?;
133 paths.graph_dir_override = Some(graph_dir.as_ref().to_path_buf());
134 Ok(paths)
135 }
136
137 #[must_use]
139 pub fn graph_dir(&self) -> PathBuf {
140 self.graph_dir_override
141 .clone()
142 .unwrap_or_else(|| self.project_root.join(".sqry").join("graph"))
143 }
144
145 #[must_use]
147 pub fn config_dir(&self) -> PathBuf {
148 self.graph_dir().join("config")
149 }
150
151 #[must_use]
153 pub fn config_file(&self) -> PathBuf {
154 self.config_dir().join("config.json")
155 }
156
157 #[must_use]
159 pub fn previous_file(&self) -> PathBuf {
160 self.config_dir().join("config.json.previous")
161 }
162
163 #[must_use]
165 pub fn lock_file(&self) -> PathBuf {
166 self.config_dir().join("config.lock")
167 }
168
169 #[must_use]
177 pub fn corrupt_file(&self, timestamp: &str) -> PathBuf {
178 self.config_dir()
179 .join(format!("config.json.corrupt.{timestamp}"))
180 }
181
182 #[must_use]
186 pub fn config_dir_exists(&self) -> bool {
187 let config_dir = self.config_dir();
188 config_dir.exists() && config_dir.is_dir()
189 }
190
191 #[must_use]
195 pub fn config_file_exists(&self) -> bool {
196 self.config_file().exists()
197 }
198
199 pub fn is_network_filesystem(&self) -> Result<bool> {
223 let path = self.graph_dir();
224
225 #[cfg(target_os = "linux")]
226 {
227 Self::is_network_filesystem_linux(&path)
228 }
229
230 #[cfg(target_os = "macos")]
231 {
232 self.is_network_filesystem_macos(&path)
233 }
234
235 #[cfg(windows)]
236 {
237 self.is_network_filesystem_windows(&path)
238 }
239
240 #[cfg(not(any(target_os = "linux", target_os = "macos", windows)))]
241 {
242 log::debug!(
243 "Network filesystem detection not implemented for this platform. \
244 Assuming local filesystem at {}",
245 path.display()
246 );
247 Ok(false)
248 }
249 }
250
251 #[cfg(target_os = "linux")]
253 fn is_network_filesystem_linux(path: &Path) -> Result<bool> {
254 use std::ffi::CString;
255
256 let path_cstr = CString::new(path.to_string_lossy().as_bytes())
257 .map_err(|e| GraphConfigError::InvalidPath(e.to_string()))?;
258
259 let mut stat: libc::statfs = unsafe { std::mem::zeroed() };
260
261 let result = unsafe { libc::statfs(path_cstr.as_ptr(), &raw mut stat) };
262
263 if result != 0 {
264 let err = std::io::Error::last_os_error();
265 if err.kind() == std::io::ErrorKind::NotFound {
267 let mut current = path.parent();
268 while let Some(parent) = current {
269 if parent.exists() {
270 return Self::is_network_filesystem_linux(parent);
271 }
272 current = parent.parent();
273 }
274 }
275 return Err(GraphConfigError::IoError(path.to_path_buf(), err));
276 }
277
278 let fs_type = i128::from(stat.f_type);
282 let is_network = matches!(
283 fs_type,
284 NFS_SUPER_MAGIC
285 | SMB_SUPER_MAGIC
286 | CIFS_MAGIC_NUMBER
287 | AFS_SUPER_MAGIC
288 | CODA_SUPER_MAGIC
289 );
290
291 if is_network {
292 log::warn!(
293 "Network filesystem detected at {} (type: 0x{:X}). \
294 Config operations may be unreliable. Consider using a local filesystem.",
295 path.display(),
296 fs_type
297 );
298 }
299
300 Ok(is_network)
301 }
302
303 #[cfg(target_os = "macos")]
308 fn is_network_filesystem_macos(&self, path: &Path) -> Result<bool> {
309 use std::ffi::CString;
310 use std::mem::MaybeUninit;
311
312 const NETWORK_FS_TYPES: &[&str] = &[
314 "nfs", "smbfs", "afpfs", "webdav", "ftp", ];
320
321 let check_path = if path.exists() {
323 path.to_path_buf()
324 } else {
325 path.ancestors()
326 .find(|p| p.exists())
327 .map(Path::to_path_buf)
328 .unwrap_or_else(|| PathBuf::from("/"))
329 };
330
331 let c_path = CString::new(check_path.as_os_str().as_encoded_bytes())
332 .map_err(|e| GraphConfigError::InvalidPath(e.to_string()))?;
333 let mut stat: MaybeUninit<libc::statfs> = MaybeUninit::uninit();
334
335 let result = unsafe { libc::statfs(c_path.as_ptr(), stat.as_mut_ptr()) };
336 if result != 0 {
337 return Ok(false);
339 }
340
341 let stat = unsafe { stat.assume_init() };
342 let fs_type = unsafe {
343 std::ffi::CStr::from_ptr(stat.f_fstypename.as_ptr())
344 .to_string_lossy()
345 .to_lowercase()
346 };
347
348 let is_network = NETWORK_FS_TYPES.iter().any(|&t| fs_type.contains(t));
349
350 if is_network {
351 log::warn!(
352 "Network filesystem detected at {} (type: {}). \
353 Config operations may be unreliable. Consider using a local filesystem.",
354 path.display(),
355 fs_type
356 );
357 }
358
359 Ok(is_network)
360 }
361
362 #[cfg(windows)]
368 fn is_network_filesystem_windows(&self, path: &Path) -> Result<bool> {
369 use std::path::{Component, Prefix};
370
371 let first_component = path.components().next();
372
373 if let Some(Component::Prefix(prefix_component)) = first_component {
374 match prefix_component.kind() {
375 Prefix::UNC(_, _) | Prefix::VerbatimUNC(_, _) => {
377 log::warn!(
378 "Network filesystem detected at {} (UNC path). \
379 Config operations may be unreliable. Consider using a local filesystem.",
380 path.display()
381 );
382 return Ok(true);
383 }
384 Prefix::Disk(_) | Prefix::VerbatimDisk(_) => {
386 let root = format!("{}\\", prefix_component.as_os_str().to_string_lossy());
387 let wide_path: Vec<u16> =
388 root.encode_utf16().chain(std::iter::once(0)).collect();
389 let drive_type = unsafe {
390 windows_sys::Win32::Storage::FileSystem::GetDriveTypeW(wide_path.as_ptr())
391 };
392 let is_network = drive_type == 4;
394 if is_network {
395 log::warn!(
396 "Network filesystem detected at {} (mapped network drive). \
397 Config operations may be unreliable. Consider using a local filesystem.",
398 path.display()
399 );
400 }
401 return Ok(is_network);
402 }
403 Prefix::DeviceNS(_) | Prefix::Verbatim(_) => {
405 return Ok(false);
406 }
407 }
408 }
409
410 Ok(false)
412 }
413
414 pub fn validate(&self, allow_network_fs: bool) -> Result<()> {
428 if !allow_network_fs && self.is_network_filesystem()? {
430 return Err(GraphConfigError::NetworkFilesystem(self.graph_dir()));
431 }
432
433 Ok(())
434 }
435}
436
437#[derive(Debug)]
451pub struct GraphConfigStore {
452 paths: GraphConfigPaths,
453}
454
455impl GraphConfigStore {
456 pub fn new<P: AsRef<Path>>(project_root: P) -> Result<Self> {
466 Ok(Self {
467 paths: GraphConfigPaths::new(project_root)?,
468 })
469 }
470
471 pub fn with_graph_dir<P: AsRef<Path>, G: AsRef<Path>>(
482 project_root: P,
483 graph_dir: G,
484 ) -> Result<Self> {
485 Ok(Self {
486 paths: GraphConfigPaths::with_graph_dir(project_root, graph_dir)?,
487 })
488 }
489
490 #[must_use]
492 pub fn paths(&self) -> &GraphConfigPaths {
493 &self.paths
494 }
495
496 pub fn validate(&self, allow_network_fs: bool) -> Result<()> {
506 self.paths.validate(allow_network_fs)
507 }
508
509 #[must_use]
511 pub fn is_initialized(&self) -> bool {
512 self.paths.config_dir_exists()
513 }
514}
515
516#[cfg(test)]
517mod tests {
518 use super::*;
519 use tempfile::TempDir;
520
521 #[test]
522 fn test_new_with_valid_path() {
523 let temp = TempDir::new().unwrap();
524 let paths = GraphConfigPaths::new(temp.path()).unwrap();
525
526 assert_eq!(paths.project_root, temp.path());
527 }
528
529 #[test]
530 fn test_new_with_nonexistent_path() {
531 let result = GraphConfigPaths::new("/nonexistent/path/that/does/not/exist");
532 assert!(result.is_err());
533 assert!(matches!(result, Err(GraphConfigError::InvalidPath(_))));
534 }
535
536 #[test]
537 fn test_graph_dir_default() {
538 let temp = TempDir::new().unwrap();
539 let paths = GraphConfigPaths::new(temp.path()).unwrap();
540
541 let expected = temp.path().join(".sqry").join("graph");
542 assert_eq!(paths.graph_dir(), expected);
543 }
544
545 #[test]
546 fn test_graph_dir_override() {
547 let temp = TempDir::new().unwrap();
548 let override_dir = temp.path().join("custom-graph");
549 std::fs::create_dir_all(&override_dir).unwrap();
550
551 let paths = GraphConfigPaths::with_graph_dir(temp.path(), &override_dir).unwrap();
552
553 assert_eq!(paths.graph_dir(), override_dir);
554 }
555
556 #[test]
557 fn test_config_dir_path() {
558 let temp = TempDir::new().unwrap();
559 let paths = GraphConfigPaths::new(temp.path()).unwrap();
560
561 let expected = temp.path().join(".sqry").join("graph").join("config");
562 assert_eq!(paths.config_dir(), expected);
563 }
564
565 #[test]
566 fn test_config_file_path() {
567 let temp = TempDir::new().unwrap();
568 let paths = GraphConfigPaths::new(temp.path()).unwrap();
569
570 let expected = temp
571 .path()
572 .join(".sqry")
573 .join("graph")
574 .join("config")
575 .join("config.json");
576 assert_eq!(paths.config_file(), expected);
577 }
578
579 #[test]
580 fn test_previous_file_path() {
581 let temp = TempDir::new().unwrap();
582 let paths = GraphConfigPaths::new(temp.path()).unwrap();
583
584 let expected = temp
585 .path()
586 .join(".sqry")
587 .join("graph")
588 .join("config")
589 .join("config.json.previous");
590 assert_eq!(paths.previous_file(), expected);
591 }
592
593 #[test]
594 fn test_lock_file_path() {
595 let temp = TempDir::new().unwrap();
596 let paths = GraphConfigPaths::new(temp.path()).unwrap();
597
598 let expected = temp
599 .path()
600 .join(".sqry")
601 .join("graph")
602 .join("config")
603 .join("config.lock");
604 assert_eq!(paths.lock_file(), expected);
605 }
606
607 #[test]
608 fn test_corrupt_file_path() {
609 let temp = TempDir::new().unwrap();
610 let paths = GraphConfigPaths::new(temp.path()).unwrap();
611
612 let timestamp = "2025-12-15T21:30:00Z";
613 let expected = temp
614 .path()
615 .join(".sqry")
616 .join("graph")
617 .join("config")
618 .join(format!("config.json.corrupt.{timestamp}"));
619 assert_eq!(paths.corrupt_file(timestamp), expected);
620 }
621
622 #[test]
623 fn test_config_dir_exists_false_when_not_created() {
624 let temp = TempDir::new().unwrap();
625 let paths = GraphConfigPaths::new(temp.path()).unwrap();
626
627 assert!(!paths.config_dir_exists());
628 }
629
630 #[test]
631 fn test_config_dir_exists_true_when_created() {
632 let temp = TempDir::new().unwrap();
633 let paths = GraphConfigPaths::new(temp.path()).unwrap();
634
635 std::fs::create_dir_all(paths.config_dir()).unwrap();
637
638 assert!(paths.config_dir_exists());
639 }
640
641 #[test]
642 fn test_config_file_exists() {
643 let temp = TempDir::new().unwrap();
644 let paths = GraphConfigPaths::new(temp.path()).unwrap();
645
646 assert!(!paths.config_file_exists());
648
649 std::fs::create_dir_all(paths.config_dir()).unwrap();
651 std::fs::write(paths.config_file(), "{}").unwrap();
652
653 assert!(paths.config_file_exists());
654 }
655
656 #[test]
657 fn test_validate_missing_config_dir_ok_for_init() {
658 let temp = TempDir::new().unwrap();
659 let paths = GraphConfigPaths::new(temp.path()).unwrap();
660
661 let result = paths.validate(false);
664 assert!(result.is_ok());
665 }
666
667 #[test]
668 #[cfg(target_os = "linux")]
669 fn test_is_network_filesystem_on_local() {
670 let temp = TempDir::new().unwrap();
671 let paths = GraphConfigPaths::new(temp.path()).unwrap();
672
673 let result = paths.is_network_filesystem();
675 assert!(result.is_ok());
676 assert!(!result.unwrap());
677 }
678
679 #[test]
680 #[cfg(target_os = "linux")]
681 fn test_is_network_filesystem_with_nonexistent_path() {
682 let temp = TempDir::new().unwrap();
683 let paths = GraphConfigPaths::new(temp.path()).unwrap();
684
685 let result = paths.is_network_filesystem();
687 assert!(result.is_ok());
688 }
689
690 #[test]
691 #[cfg(target_os = "macos")]
692 fn test_is_network_filesystem_local_on_macos() {
693 let temp = TempDir::new().unwrap();
694 let paths = GraphConfigPaths::new(temp.path()).unwrap();
695
696 let result = paths.is_network_filesystem();
698 assert!(result.is_ok());
699 assert!(!result.unwrap());
700 }
701
702 #[test]
703 #[cfg(target_os = "macos")]
704 fn test_is_network_filesystem_nonexistent_path_macos() {
705 let temp = TempDir::new().unwrap();
706 let nonexistent = temp.path().join("does").join("not").join("exist");
708 let paths = GraphConfigPaths::with_graph_dir(temp.path(), &nonexistent).unwrap();
709
710 let result = paths.is_network_filesystem();
712 assert!(result.is_ok());
713 assert!(!result.unwrap());
714 }
715
716 #[test]
717 #[cfg(windows)]
718 fn test_is_network_filesystem_local_on_windows() {
719 let temp = TempDir::new().unwrap();
720 let paths = GraphConfigPaths::new(temp.path()).unwrap();
721
722 let result = paths.is_network_filesystem();
724 assert!(result.is_ok());
725 assert!(!result.unwrap());
726 }
727
728 #[test]
729 #[cfg(windows)]
730 fn test_is_network_filesystem_unc_path() {
731 let temp = TempDir::new().unwrap();
732 let unc_path = PathBuf::from(r"\\server\share\project\.sqry\graph");
734 let paths = GraphConfigPaths {
735 project_root: temp.path().to_path_buf(),
736 graph_dir_override: Some(unc_path),
737 };
738
739 let result = paths.is_network_filesystem();
741 assert!(result.is_ok());
742 assert!(result.unwrap());
743 }
744
745 #[test]
746 fn test_store_new() {
747 let temp = TempDir::new().unwrap();
748 let store = GraphConfigStore::new(temp.path()).unwrap();
749
750 assert_eq!(store.paths.project_root, temp.path());
751 }
752
753 #[test]
754 fn test_store_with_graph_dir() {
755 let temp = TempDir::new().unwrap();
756 let override_dir = temp.path().join("custom");
757 std::fs::create_dir_all(&override_dir).unwrap();
758
759 let store = GraphConfigStore::with_graph_dir(temp.path(), &override_dir).unwrap();
760
761 assert_eq!(store.paths.graph_dir(), override_dir);
762 }
763
764 #[test]
765 fn test_store_is_initialized() {
766 let temp = TempDir::new().unwrap();
767 let store = GraphConfigStore::new(temp.path()).unwrap();
768
769 assert!(!store.is_initialized());
771
772 std::fs::create_dir_all(store.paths.config_dir()).unwrap();
774
775 assert!(store.is_initialized());
776 }
777
778 #[test]
779 fn test_store_validate() {
780 let temp = TempDir::new().unwrap();
781 let store = GraphConfigStore::new(temp.path()).unwrap();
782
783 let result = store.validate(false);
785 assert!(result.is_ok());
786 }
787
788 #[test]
789 fn test_paths_accessor() {
790 let temp = TempDir::new().unwrap();
791 let store = GraphConfigStore::new(temp.path()).unwrap();
792
793 let paths = store.paths();
794 assert_eq!(paths.project_root, temp.path());
795 }
796}