1use crate::config;
11use crate::errors::AppError;
12use crate::i18n::validation;
13use directories::ProjectDirs;
14use std::path::{Component, Path, PathBuf};
15
16#[derive(Debug, Clone)]
18pub struct AppPaths {
19 pub db: PathBuf,
21 pub models: PathBuf,
23}
24
25impl AppPaths {
26 pub fn resolve(db_override: Option<&str>) -> Result<Self, AppError> {
27 let proj = ProjectDirs::from("", "", "sqlite-graphrag").ok_or_else(|| {
28 AppError::Io(std::io::Error::other("could not determine home directory"))
29 })?;
30
31 let cache_root = if let Ok(Some(override_dir)) = config::get_setting("cache.dir") {
33 if !override_dir.is_empty() {
34 validate_path(&override_dir)?;
35 PathBuf::from(override_dir)
36 } else {
37 proj.cache_dir().to_path_buf()
38 }
39 } else {
40 proj.cache_dir().to_path_buf()
41 };
42
43 let db = if let Some(p) = db_override {
44 validate_path(p)?;
45 PathBuf::from(p)
46 } else if let Ok(Some(cfg_path)) = config::get_setting("db.path") {
47 if !cfg_path.is_empty() {
48 validate_path(&cfg_path)?;
49 PathBuf::from(cfg_path)
50 } else {
51 default_db_path(&proj)?
52 }
53 } else {
54 default_db_path(&proj)?
55 };
56
57 Ok(Self {
58 db,
59 models: cache_root.join("models"),
60 })
61 }
62
63 pub fn ensure_dirs(&self) -> Result<(), AppError> {
64 for dir in [parent_or_err(&self.db)?, self.models.as_path()] {
65 std::fs::create_dir_all(dir)?;
66 }
67 Ok(())
68 }
69}
70
71fn default_db_path(proj: &ProjectDirs) -> Result<PathBuf, AppError> {
72 let data = proj.data_dir();
74 if data.as_os_str().is_empty() {
75 return Ok(std::env::current_dir()
76 .map_err(AppError::Io)?
77 .join("graphrag.sqlite"));
78 }
79 Ok(data.join("graphrag.sqlite"))
80}
81
82fn validate_path(p: &str) -> Result<(), AppError> {
83 if Path::new(p).components().any(|c| c == Component::ParentDir) {
84 return Err(AppError::Validation(validation::path_traversal(p)));
85 }
86 Ok(())
87}
88
89pub fn config_dir() -> Result<PathBuf, AppError> {
91 let proj = ProjectDirs::from("", "", "sqlite-graphrag").ok_or_else(|| {
92 AppError::Io(std::io::Error::other(
93 "could not determine home directory for config",
94 ))
95 })?;
96 Ok(proj.config_dir().to_path_buf())
97}
98
99pub(crate) fn parent_or_err(path: &Path) -> Result<&Path, AppError> {
100 path.parent().ok_or_else(|| {
101 AppError::Validation(format!(
102 "path '{}' has no valid parent component",
103 path.display()
104 ))
105 })
106}
107
108pub fn sidecar_path(db_path: &Path, filename: &str) -> PathBuf {
113 db_path
114 .parent()
115 .filter(|p| !p.as_os_str().is_empty())
116 .map(|p| p.join(filename))
117 .unwrap_or_else(|| PathBuf::from(filename))
118}
119
120#[cfg(test)]
121mod tests {
122 use super::*;
123 use tempfile::TempDir;
124
125 #[test]
126 fn flag_overrides_default() {
127 let tmp = TempDir::new().expect("tempdir");
128 let db_flag = tmp.path().join("via-flag.sqlite");
129 let paths = AppPaths::resolve(Some(db_flag.to_str().expect("utf8")))
130 .expect("resolve with flag");
131 assert_eq!(paths.db, db_flag);
132 }
133
134 #[test]
135 fn traversal_in_flag_rejected() {
136 let result = AppPaths::resolve(Some("/tmp/../etc/passwd"));
137 assert!(
138 matches!(result, Err(AppError::Validation(_))),
139 "traversal must fail as Validation, got {result:?}"
140 );
141 }
142
143 #[test]
144 fn default_resolve_ok() {
145 let paths = AppPaths::resolve(None).expect("default resolve");
146 assert!(!paths.db.as_os_str().is_empty());
147 assert!(paths.models.ends_with("models"));
148 }
149
150 #[test]
151 fn sidecar_path_joins_parent() {
152 let p = sidecar_path(Path::new("/data/db/graphrag.sqlite"), "enrich.queue");
153 assert_eq!(p, PathBuf::from("/data/db/enrich.queue"));
154 }
155}