1use crate::errors::AppError;
7use crate::i18n::validation;
8use directories::ProjectDirs;
9use std::path::{Component, Path, PathBuf};
10
11#[derive(Debug)]
12pub struct AppPaths {
13 pub db: PathBuf,
14 pub models: PathBuf,
15}
16
17impl AppPaths {
18 pub fn resolve(db_override: Option<&str>) -> Result<Self, AppError> {
19 let proj = ProjectDirs::from("", "", "sqlite-graphrag").ok_or_else(|| {
20 AppError::Io(std::io::Error::other("could not determine home directory"))
21 })?;
22
23 let cache_root = if let Some(override_dir) = std::env::var_os("SQLITE_GRAPHRAG_CACHE_DIR") {
24 PathBuf::from(override_dir)
25 } else {
26 proj.cache_dir().to_path_buf()
27 };
28
29 let db = if let Some(p) = db_override {
30 validate_path(p)?;
31 PathBuf::from(p)
32 } else if let Ok(env_path) = std::env::var("SQLITE_GRAPHRAG_DB_PATH") {
33 validate_path(&env_path)?;
34 PathBuf::from(env_path)
35 } else if let Some(home_dir) = home_env_dir()? {
36 home_dir.join("graphrag.sqlite")
37 } else {
38 std::env::current_dir()
39 .map_err(AppError::Io)?
40 .join("graphrag.sqlite")
41 };
42
43 Ok(Self {
44 db,
45 models: cache_root.join("models"),
46 })
47 }
48
49 pub fn ensure_dirs(&self) -> Result<(), AppError> {
50 for dir in [parent_or_err(&self.db)?, self.models.as_path()] {
51 std::fs::create_dir_all(dir)?;
52 }
53 Ok(())
54 }
55}
56
57fn validate_path(p: &str) -> Result<(), AppError> {
58 if Path::new(p).components().any(|c| c == Component::ParentDir) {
59 return Err(AppError::Validation(validation::path_traversal(p)));
60 }
61 Ok(())
62}
63
64fn home_env_dir() -> Result<Option<PathBuf>, AppError> {
70 let raw = match std::env::var("SQLITE_GRAPHRAG_HOME") {
71 Ok(v) => v,
72 Err(_) => return Ok(None),
73 };
74 if raw.is_empty() {
75 return Ok(None);
76 }
77 validate_path(&raw)?;
78 Ok(Some(PathBuf::from(raw)))
79}
80
81pub(crate) fn parent_or_err(path: &Path) -> Result<&Path, AppError> {
82 path.parent().ok_or_else(|| {
83 AppError::Validation(format!(
84 "path '{}' has no valid parent component",
85 path.display()
86 ))
87 })
88}
89
90#[cfg(test)]
91mod tests {
92 use super::*;
93 use serial_test::serial;
94 use tempfile::TempDir;
95
96 fn clean_env_paths() {
99 unsafe {
101 std::env::remove_var("SQLITE_GRAPHRAG_HOME");
102 std::env::remove_var("SQLITE_GRAPHRAG_DB_PATH");
103 std::env::remove_var("SQLITE_GRAPHRAG_CACHE_DIR");
104 }
105 }
106
107 #[test]
108 #[serial]
109 fn home_env_resolves_db_in_subdir() {
110 clean_env_paths();
111 let tmp = TempDir::new().expect("tempdir");
112 unsafe {
114 std::env::set_var("SQLITE_GRAPHRAG_HOME", tmp.path());
115 }
116
117 let paths = AppPaths::resolve(None).expect("resolve with valid HOME");
118 assert_eq!(paths.db, tmp.path().join("graphrag.sqlite"));
119
120 clean_env_paths();
121 }
122
123 #[test]
124 #[serial]
125 fn home_env_traversal_rejected() {
126 clean_env_paths();
127 unsafe {
129 std::env::set_var("SQLITE_GRAPHRAG_HOME", "/tmp/../etc");
130 }
131
132 let result = AppPaths::resolve(None);
133 assert!(
134 matches!(result, Err(AppError::Validation(_))),
135 "traversal in SQLITE_GRAPHRAG_HOME must fail as Validation, got {result:?}"
136 );
137
138 clean_env_paths();
139 }
140
141 #[test]
142 #[serial]
143 fn db_path_overrides_home() {
144 clean_env_paths();
145 let tmp_home = TempDir::new().expect("tempdir home");
146 let tmp_db = TempDir::new().expect("tempdir db");
147 let explicit_db = tmp_db.path().join("explicit.sqlite");
148 unsafe {
150 std::env::set_var("SQLITE_GRAPHRAG_HOME", tmp_home.path());
151 std::env::set_var("SQLITE_GRAPHRAG_DB_PATH", &explicit_db);
152 }
153
154 let paths = AppPaths::resolve(None).expect("resolve with DB_PATH and HOME");
155 assert_eq!(paths.db, explicit_db);
156
157 clean_env_paths();
158 }
159
160 #[test]
161 #[serial]
162 fn flag_overrides_home() {
163 clean_env_paths();
164 let tmp_home = TempDir::new().expect("tempdir home");
165 let tmp_flag = TempDir::new().expect("tempdir flag");
166 let db_flag = tmp_flag.path().join("via-flag.sqlite");
167 unsafe {
169 std::env::set_var("SQLITE_GRAPHRAG_HOME", tmp_home.path());
170 }
171
172 let paths = AppPaths::resolve(Some(db_flag.to_str().expect("utf8")))
173 .expect("resolve with flag and HOME");
174 assert_eq!(paths.db, db_flag);
175
176 clean_env_paths();
177 }
178
179 #[test]
180 #[serial]
181 fn home_env_empty_falls_back_to_cwd() {
182 clean_env_paths();
183 unsafe {
185 std::env::set_var("SQLITE_GRAPHRAG_HOME", "");
186 }
187
188 let paths = AppPaths::resolve(None).expect("resolve with empty HOME");
189 let expected = std::env::current_dir()
190 .expect("cwd")
191 .join("graphrag.sqlite");
192 assert_eq!(paths.db, expected);
193
194 clean_env_paths();
195 }
196
197 #[test]
198 fn parent_or_err_accepts_normal_path() {
199 let p = PathBuf::from("/home/user/db.sqlite");
200 let parent = parent_or_err(&p).expect("valid parent");
201 assert_eq!(parent, Path::new("/home/user"));
202 }
203
204 #[test]
205 fn parent_or_err_accepts_relative_path() {
206 let p = PathBuf::from("subdir/file.sqlite");
207 let parent = parent_or_err(&p).expect("relative parent");
208 assert_eq!(parent, Path::new("subdir"));
209 }
210
211 #[test]
212 fn parent_or_err_rejects_unix_root() {
213 let p = PathBuf::from("/");
214 let result = parent_or_err(&p);
215 assert!(matches!(result, Err(AppError::Validation(_))));
216 }
217
218 #[test]
219 fn parent_or_err_rejects_empty_path() {
220 let p = PathBuf::from("");
221 let result = parent_or_err(&p);
222 assert!(matches!(result, Err(AppError::Validation(_))));
223 }
224}