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