use crate::config;
use crate::errors::AppError;
use crate::i18n::validation;
use crate::runtime_config;
use directories::ProjectDirs;
use std::path::{Component, Path, PathBuf};
#[derive(Debug, Clone)]
pub struct AppPaths {
pub db: PathBuf,
pub models: PathBuf,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TargetSource {
Argv,
Xdg,
Default,
}
impl TargetSource {
pub fn as_str(self) -> &'static str {
match self {
Self::Argv => "argv",
Self::Xdg => "xdg",
Self::Default => "default",
}
}
}
#[derive(Debug, Clone, Copy)]
pub struct WritePolicy {
pub requires_explicit_target: bool,
pub use_active: bool,
}
static WRITE_POLICY: std::sync::OnceLock<WritePolicy> = std::sync::OnceLock::new();
pub fn install_write_policy(policy: WritePolicy) {
let _ = WRITE_POLICY.set(policy);
}
fn enforce_explicit_target(source: TargetSource) -> Result<(), AppError> {
let Some(policy) = WRITE_POLICY.get() else {
return Ok(());
};
if source == TargetSource::Argv || !policy.requires_explicit_target || policy.use_active {
return Ok(());
}
let message = match source {
TargetSource::Xdg => validation::target_inherited_from_config(),
_ => validation::target_not_designated(),
};
Err(AppError::Usage {
message,
discarded_flags: Vec::new(),
})
}
static RESOLVED_TARGET: std::sync::OnceLock<(PathBuf, TargetSource)> = std::sync::OnceLock::new();
fn record_target(db: &std::path::Path, source: TargetSource) {
let _ = RESOLVED_TARGET.set((db.to_path_buf(), source));
}
impl AppPaths {
pub fn target_source() -> Option<TargetSource> {
RESOLVED_TARGET.get().map(|(_, source)| *source)
}
pub fn resolved_target() -> Option<&'static std::path::Path> {
RESOLVED_TARGET.get().map(|(path, _)| path.as_path())
}
pub fn resolve(db_override: Option<&str>) -> Result<Self, AppError> {
let proj = ProjectDirs::from("", "", "sqlite-graphrag").ok_or_else(|| {
AppError::Io(std::io::Error::other("could not determine home directory"))
})?;
let cache_root = cache_dir()?;
let (db, source) = if let Some(p) = db_override {
validate_path(p)?;
(PathBuf::from(p), TargetSource::Argv)
} else {
match config::get_setting("db.path") {
Ok(Some(cfg_path)) if !cfg_path.is_empty() => {
validate_path(&cfg_path)?;
(PathBuf::from(cfg_path), TargetSource::Xdg)
}
_ => (default_db_path(&proj)?, TargetSource::Default),
}
};
enforce_explicit_target(source)?;
record_target(&db, source);
Ok(Self {
db,
models: cache_root.join("models"),
})
}
pub fn ensure_dirs(&self) -> Result<(), AppError> {
for dir in [parent_or_err(&self.db)?, self.models.as_path()] {
std::fs::create_dir_all(dir)?;
}
Ok(())
}
}
fn default_db_path(proj: &ProjectDirs) -> Result<PathBuf, AppError> {
let data = proj.data_dir();
if data.as_os_str().is_empty() {
return Ok(std::env::current_dir()
.map_err(AppError::Io)?
.join("graphrag.sqlite"));
}
Ok(data.join("graphrag.sqlite"))
}
fn validate_path(p: &str) -> Result<(), AppError> {
if Path::new(p).components().any(|c| c == Component::ParentDir) {
return Err(AppError::Validation(validation::path_traversal(p)));
}
Ok(())
}
pub fn config_dir() -> Result<PathBuf, AppError> {
if let Some(dir) = runtime_config::config_dir_override() {
validate_path(&dir)?;
return Ok(PathBuf::from(dir));
}
let proj = ProjectDirs::from("", "", "sqlite-graphrag").ok_or_else(|| {
AppError::Io(std::io::Error::other(
"could not determine home directory for config",
))
})?;
Ok(proj.config_dir().to_path_buf())
}
pub fn cache_dir() -> Result<PathBuf, AppError> {
if let Some(dir) = runtime_config::cache_dir_override() {
validate_path(&dir)?;
return Ok(PathBuf::from(dir));
}
let proj = ProjectDirs::from("", "", "sqlite-graphrag").ok_or_else(|| {
AppError::Io(std::io::Error::other(
"could not determine cache directory for sqlite-graphrag",
))
})?;
Ok(proj.cache_dir().to_path_buf())
}
pub(crate) fn parent_or_err(path: &Path) -> Result<&Path, AppError> {
path.parent().ok_or_else(|| {
AppError::Validation(validation::path_no_valid_parent(
&path.display().to_string(),
))
})
}
pub fn sidecar_path(db_path: &Path, filename: &str) -> PathBuf {
db_path
.parent()
.filter(|p| !p.as_os_str().is_empty())
.map(|p| p.join(filename))
.unwrap_or_else(|| PathBuf::from(filename))
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn flag_overrides_default() {
let tmp = TempDir::new().expect("tempdir");
let db_flag = tmp.path().join("via-flag.sqlite");
let paths =
AppPaths::resolve(Some(db_flag.to_str().expect("utf8"))).expect("resolve with flag");
assert_eq!(paths.db, db_flag);
}
#[test]
fn traversal_in_flag_rejected() {
let result = AppPaths::resolve(Some("/tmp/../etc/passwd"));
assert!(
matches!(result, Err(AppError::Validation(_))),
"traversal must fail as Validation, got {result:?}"
);
}
#[test]
fn default_resolve_ok() {
let paths = AppPaths::resolve(None).expect("default resolve");
assert!(!paths.db.as_os_str().is_empty());
assert!(paths.models.ends_with("models"));
}
#[test]
fn sidecar_path_joins_parent() {
let p = sidecar_path(Path::new("/data/db/graphrag.sqlite"), "enrich.queue");
assert_eq!(p, PathBuf::from("/data/db/enrich.queue"));
}
}