use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use crate::shared::i18n::{Lang, Locale};
pub const DEFAULTS_MARKER: &str = "defaults.json";
pub const LEGACY_LOCATION_MARKER: &str = "location.json";
pub const PORTABLE_DATA_SUBDIR: &str = "data";
pub const DICTIONARIES_README: &str = "README.txt";
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(tag = "mode", rename_all = "lowercase")]
pub enum DataLocation {
#[default]
Portable,
System,
Path { path: String },
}
impl DataLocation {
pub fn root_dir(&self, exe_dir: &Path) -> Result<PathBuf> {
match self {
Self::Portable => Ok(exe_dir.join(PORTABLE_DATA_SUBDIR)),
Self::System => {
let dirs = directories::ProjectDirs::from("", "", "mindfork-rs")
.context("cannot determine the standard OS data folder")?;
Ok(dirs.data_dir().to_path_buf())
}
Self::Path { path } => {
let trimmed = path.trim();
anyhow::ensure!(
!trimmed.is_empty(),
"in \"path\" mode the {DEFAULTS_MARKER} file must specify a non-empty path"
);
Ok(PathBuf::from(trimmed))
}
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct Defaults {
#[serde(flatten, default)]
pub location: DataLocation,
#[serde(default)]
pub default_language: Option<Lang>,
}
fn strip_bom(bytes: &[u8]) -> &[u8] {
bytes.strip_prefix(&[0xEF, 0xBB, 0xBF]).unwrap_or(bytes)
}
impl Defaults {
pub fn read(exe_dir: &Path) -> Result<Self> {
let primary = exe_dir.join(DEFAULTS_MARKER);
let marker = if primary.exists() {
primary
} else {
exe_dir.join(LEGACY_LOCATION_MARKER)
};
match std::fs::read(&marker) {
Ok(bytes) => {
let bytes = strip_bom(&bytes);
if bytes.iter().all(u8::is_ascii_whitespace) {
Ok(Self::default())
} else {
serde_json::from_slice(bytes)
.with_context(|| format!("parsing defaults file {}", marker.display()))
}
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
Err(e) => Err(e).with_context(|| format!("reading defaults file {}", marker.display())),
}
}
}
#[derive(Debug, Clone)]
pub struct Paths {
root: PathBuf,
default_language: Lang,
exe_dir: Option<PathBuf>,
sandbox_override: Option<PathBuf>,
location: DataLocation,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Locations {
pub program: Option<PathBuf>,
pub data: PathBuf,
pub storage: DataLocation,
pub logs: PathBuf,
}
impl Paths {
pub fn resolve() -> Result<Self> {
let exe = std::env::current_exe().context("cannot resolve current executable path")?;
let exe_dir = exe_dir_of(exe)?;
let defaults = Defaults::read(&exe_dir)?;
let root = defaults.location.root_dir(&exe_dir)?;
let mut paths = Self::with_root(root);
paths.default_language = defaults
.default_language
.unwrap_or_else(crate::shared::i18n::detect_os_language);
paths.exe_dir = Some(exe_dir);
paths.location = defaults.location;
Ok(paths)
}
pub fn ensure_dirs(&self, loc: &Locale) -> Result<()> {
std::fs::create_dir_all(&self.root)?;
Self::restrict_to_owner(&self.root);
let _ = std::fs::create_dir_all(self.locales_dir());
let _ = std::fs::create_dir_all(self.themes_dir());
self.seed_dictionaries_dir(loc);
Ok(())
}
fn seed_dictionaries_dir(&self, loc: &Locale) {
let dir = self.dictionaries_dir();
if dir.exists() || std::fs::create_dir_all(&dir).is_err() {
return;
}
let text = loc.t("dictionaries.readme");
#[cfg(windows)]
let text = text.replace('\n', "\r\n");
let _ = std::fs::write(dir.join(DICTIONARIES_README), text.as_bytes());
}
pub fn with_root(root: impl Into<PathBuf>) -> Self {
Self {
root: root.into(),
default_language: Lang::default(),
exe_dir: None,
sandbox_override: None,
location: DataLocation::default(),
}
}
pub fn locations(&self) -> Locations {
let absolute = |path: PathBuf| std::path::absolute(&path).unwrap_or(path);
Locations {
program: self.exe_dir.clone(),
data: absolute(self.root.clone()),
storage: self.location.clone(),
logs: absolute(self.log_dir()),
}
}
#[cfg(test)]
pub fn with_default_language(mut self, lang: Lang) -> Self {
self.default_language = lang;
self
}
#[cfg(test)]
pub fn with_sandbox_dir(mut self, dir: impl Into<PathBuf>) -> Self {
self.sandbox_override = Some(dir.into());
self
}
pub fn root(&self) -> &Path {
&self.root
}
pub fn default_language(&self) -> Lang {
self.default_language
}
pub fn settings_file(&self) -> PathBuf {
self.root.join("settings.json")
}
pub fn profiles_file(&self) -> PathBuf {
self.root.join("profiles.json")
}
pub fn chats_dir(&self) -> PathBuf {
self.root.join("chats")
}
pub fn chat_file(&self, chat_id: &str) -> PathBuf {
self.chats_dir().join(format!("{chat_id}.json"))
}
pub fn data_db(&self) -> PathBuf {
self.root.join("data.db")
}
pub fn workspace_dir(&self) -> PathBuf {
self.root.join("workspace")
}
pub fn files_dir(&self) -> PathBuf {
self.root.join("files")
}
pub fn cache_db(&self) -> PathBuf {
self.root.join("cache.db")
}
pub fn dictionaries_dir(&self) -> PathBuf {
self.root.join("dictionaries")
}
pub fn bundled_dictionaries_dir(&self) -> Option<PathBuf> {
self.exe_dir
.as_ref()
.map(|d| d.join(PORTABLE_DATA_SUBDIR).join("dictionaries"))
}
pub fn exe_dir(&self) -> Option<&Path> {
self.exe_dir.as_deref()
}
#[cfg(unix)]
fn restrict_to_owner(dir: &Path) {
use std::os::unix::fs::PermissionsExt;
if let Err(err) = std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700)) {
tracing::debug!(path = %dir.display(), %err, "could not restrict the data root");
}
}
#[cfg(not(unix))]
fn restrict_to_owner(_dir: &Path) {}
pub fn app_dirs(&self) -> Vec<PathBuf> {
let mut dirs = vec![self.root.clone()];
dirs.extend(self.exe_dir.clone());
dirs
}
pub fn locales_dir(&self) -> PathBuf {
self.root.join("locales")
}
pub fn themes_dir(&self) -> PathBuf {
self.root.join("themes")
}
pub fn personal_dictionary(&self) -> PathBuf {
self.root.join("personal_dictionary.txt")
}
pub fn log_dir(&self) -> PathBuf {
self.root.join("logs")
}
pub fn cuda_cache_dir(&self) -> PathBuf {
self.root.join("cuda-cache")
}
pub fn backups_dir(&self) -> PathBuf {
self.root.join("backups")
}
pub fn sandbox_dir(&self) -> PathBuf {
self.sandbox_override
.clone()
.unwrap_or_else(|| self.root.join("sandbox"))
}
pub fn llama_dir(&self) -> PathBuf {
self.root.join("llama")
}
}
fn exe_dir_of(exe: PathBuf) -> Result<PathBuf> {
#[cfg(unix)]
let exe = std::fs::canonicalize(&exe).unwrap_or(exe);
Ok(exe
.parent()
.context("cannot determine executable directory")?
.to_path_buf())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn files_live_under_root() {
let p = Paths::with_root("data-root");
assert!(p.settings_file().ends_with("settings.json"));
assert!(p.profiles_file().ends_with("profiles.json"));
assert!(p.data_db().ends_with("data.db"));
assert!(p.personal_dictionary().ends_with("personal_dictionary.txt"));
assert!(p.chats_dir().ends_with("chats"));
assert!(p.dictionaries_dir().ends_with("dictionaries"));
assert!(p.log_dir().ends_with("logs"));
assert!(p.cuda_cache_dir().ends_with("cuda-cache"));
}
#[test]
fn chat_file_is_named_by_id_under_chats_dir() {
let p = Paths::with_root("data-root");
let f = p.chat_file("abc123");
assert!(f.ends_with("abc123.json"));
assert_eq!(f.parent(), Some(p.chats_dir().as_path()));
}
#[test]
fn root_is_preserved() {
let p = Paths::with_root("some/root");
assert_eq!(p.root(), Path::new("some/root"));
}
#[test]
fn locations_are_absolute_and_name_the_mode() {
let p = Paths::with_root("some/root");
let at = p.locations();
assert!(
at.data.is_absolute(),
"a relative root shown as given: {at:?}"
);
assert_eq!(at.data, std::env::current_dir().unwrap().join("some/root"));
assert_eq!(at.logs, at.data.join("logs"));
assert_eq!(at.program, None);
assert_eq!(at.storage, DataLocation::Portable);
let root = std::env::temp_dir().join("mindfork-locations");
assert_eq!(Paths::with_root(&root).locations().data, root);
}
#[test]
fn cache_db_under_root_next_to_data_db() {
let p = Paths::with_root("data-root");
assert!(p.cache_db().ends_with("cache.db"));
assert_eq!(p.cache_db().parent(), Some(p.root()));
assert_ne!(p.cache_db(), p.data_db());
}
#[test]
fn backups_dir_under_root() {
let p = Paths::with_root("data-root");
assert!(p.backups_dir().ends_with("backups"));
assert_eq!(p.backups_dir().parent(), Some(p.root()));
}
#[test]
fn themes_dir_under_root() {
let p = Paths::with_root(PathBuf::from("/tmp/mindfork"));
assert!(p.themes_dir().ends_with("themes"));
assert_eq!(p.themes_dir().parent(), Some(p.root()));
}
#[test]
fn the_directories_for_a_users_files_are_created_empty() {
let dir = tempfile::tempdir().unwrap();
let p = Paths::with_root(dir.path().join("data"));
p.ensure_dirs(crate::shared::i18n::locale(crate::shared::i18n::Lang::En))
.unwrap();
for made in [p.themes_dir(), p.locales_dir()] {
assert!(made.is_dir(), "{}", made.display());
assert_eq!(std::fs::read_dir(&made).unwrap().count(), 0);
}
}
#[test]
fn locales_dir_under_root() {
let p = Paths::with_root("data-root");
assert!(p.locales_dir().ends_with("locales"));
assert_eq!(p.locales_dir().parent(), Some(p.root()));
}
#[test]
fn missing_marker_is_portable() {
let dir = tempfile::tempdir().unwrap();
let loc = Defaults::read(dir.path()).unwrap().location;
assert_eq!(loc, DataLocation::Portable);
assert_eq!(
loc.root_dir(dir.path()).unwrap(),
dir.path().join(PORTABLE_DATA_SUBDIR)
);
}
#[test]
fn whitespace_marker_is_portable() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join(DEFAULTS_MARKER), " \n\t").unwrap();
assert_eq!(
Defaults::read(dir.path()).unwrap().location,
DataLocation::Portable
);
}
#[test]
fn portable_and_path_modes_round_trip() {
for (json, expect) in [
(r#"{"mode":"portable"}"#, DataLocation::Portable),
(r#"{"mode":"system"}"#, DataLocation::System),
(
r#"{"mode":"path","path":"D:\\data"}"#,
DataLocation::Path {
path: "D:\\data".into(),
},
),
] {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join(DEFAULTS_MARKER), json).unwrap();
assert_eq!(Defaults::read(dir.path()).unwrap().location, expect);
}
}
#[test]
fn explicit_path_mode_resolves_to_that_path() {
let loc = DataLocation::Path {
path: " custom/data/dir ".into(),
};
assert_eq!(
loc.root_dir(Path::new("/exe")).unwrap(),
PathBuf::from("custom/data/dir")
);
}
#[test]
fn empty_path_mode_errors() {
let loc = DataLocation::Path { path: " ".into() };
assert!(loc.root_dir(Path::new("/exe")).is_err());
}
#[test]
fn corrupted_marker_errors() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join(DEFAULTS_MARKER), "{ not valid json").unwrap();
assert!(Defaults::read(dir.path()).is_err());
}
#[test]
fn system_mode_root_mentions_app() {
let root = DataLocation::System.root_dir(Path::new("/exe")).unwrap();
assert!(root.to_string_lossy().contains("mindfork-rs"));
}
#[test]
fn defaults_reads_storage_and_language() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join(DEFAULTS_MARKER),
r#"{"mode":"path","path":"D:\\data","default_language":"en"}"#,
)
.unwrap();
let d = Defaults::read(dir.path()).unwrap();
assert_eq!(
d.location,
DataLocation::Path {
path: "D:\\data".into()
}
);
assert_eq!(d.default_language, Some(Lang::En));
}
#[test]
fn defaults_missing_has_no_explicit_language() {
let dir = tempfile::tempdir().unwrap();
let d = Defaults::read(dir.path()).unwrap();
assert_eq!(d, Defaults::default());
assert_eq!(d.location, DataLocation::Portable);
assert_eq!(d.default_language, None);
}
#[test]
fn defaults_without_language_field_is_none() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join(DEFAULTS_MARKER), r#"{"mode":"system"}"#).unwrap();
let d = Defaults::read(dir.path()).unwrap();
assert_eq!(d.location, DataLocation::System);
assert_eq!(d.default_language, None);
}
#[test]
fn defaults_falls_back_to_legacy_location_marker() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join(LEGACY_LOCATION_MARKER),
r#"{"mode":"system"}"#,
)
.unwrap();
let d = Defaults::read(dir.path()).unwrap();
assert_eq!(d.location, DataLocation::System);
assert_eq!(d.default_language, None);
}
#[test]
fn defaults_json_takes_precedence_over_legacy() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join(DEFAULTS_MARKER),
r#"{"mode":"portable","default_language":"en"}"#,
)
.unwrap();
std::fs::write(
dir.path().join(LEGACY_LOCATION_MARKER),
r#"{"mode":"system"}"#,
)
.unwrap();
let d = Defaults::read(dir.path()).unwrap();
assert_eq!(d.location, DataLocation::Portable);
assert_eq!(d.default_language, Some(Lang::En));
}
#[test]
fn defaults_tolerates_utf8_bom() {
let dir = tempfile::tempdir().unwrap();
let mut bytes = vec![0xEF, 0xBB, 0xBF];
bytes.extend_from_slice(br#"{"mode":"system","default_language":"en"}"#);
std::fs::write(dir.path().join(DEFAULTS_MARKER), bytes).unwrap();
let d = Defaults::read(dir.path()).unwrap();
assert_eq!(d.location, DataLocation::System);
assert_eq!(d.default_language, Some(Lang::En));
}
#[test]
fn defaults_corrupted_errors() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join(DEFAULTS_MARKER), "{ bad json").unwrap();
assert!(Defaults::read(dir.path()).is_err());
}
#[test]
fn with_root_defaults_to_ru_language() {
assert_eq!(Paths::with_root("r").default_language(), Lang::Ru);
}
#[test]
fn with_root_has_no_bundled_dictionaries() {
assert_eq!(Paths::with_root("r").bundled_dictionaries_dir(), None);
}
fn fresh_root(dir: &tempfile::TempDir) -> Paths {
Paths::with_root(dir.path().join("data"))
}
#[cfg(unix)]
#[test]
fn a_symlinked_binary_keeps_its_data_beside_the_real_file() {
let dir = tempfile::tempdir().unwrap();
let (real, links) = (dir.path().join("app"), dir.path().join("bin"));
std::fs::create_dir_all(&real).unwrap();
std::fs::create_dir_all(&links).unwrap();
std::fs::write(real.join("mindfork"), b"").unwrap();
std::os::unix::fs::symlink(real.join("mindfork"), links.join("mindfork")).unwrap();
let got = exe_dir_of(links.join("mindfork")).unwrap();
assert_eq!(got, std::fs::canonicalize(&real).unwrap());
}
#[cfg(unix)]
#[test]
fn ensure_dirs_restricts_an_existing_root_to_its_owner() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let root = dir.path().join("data");
std::fs::create_dir_all(&root).unwrap();
std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o755)).unwrap();
Paths::with_root(&root)
.ensure_dirs(crate::shared::i18n::locale(Lang::En))
.unwrap();
let mode = std::fs::metadata(&root).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o700, "the root stayed readable to others: {mode:o}");
}
#[test]
fn ensure_dirs_creates_a_dictionaries_directory_with_an_invitation() {
let dir = tempfile::tempdir().unwrap();
let paths = fresh_root(&dir);
paths
.ensure_dirs(crate::shared::i18n::locale(Lang::En))
.unwrap();
let readme = paths.dictionaries_dir().join(DICTIONARIES_README);
let text = std::fs::read_to_string(&readme).unwrap();
assert!(text.contains(".aff") && text.contains(".dic"), "{text}");
}
#[test]
fn the_invitation_is_written_in_the_given_language() {
let mut texts = Vec::new();
for lang in [Lang::En, Lang::Ru] {
let dir = tempfile::tempdir().unwrap();
let paths = fresh_root(&dir);
paths
.ensure_dirs(crate::shared::i18n::locale(lang))
.unwrap();
texts.push(
std::fs::read_to_string(paths.dictionaries_dir().join(DICTIONARIES_README))
.unwrap(),
);
}
assert_ne!(texts[0], texts[1]);
assert!(!texts[0].chars().any(|c| ('а'..='я').contains(&c)));
assert!(texts[1].chars().any(|c| ('а'..='я').contains(&c)));
}
#[test]
fn an_existing_dictionaries_directory_is_left_alone() {
let dir = tempfile::tempdir().unwrap();
let paths = fresh_root(&dir);
std::fs::create_dir_all(paths.dictionaries_dir()).unwrap();
paths
.ensure_dirs(crate::shared::i18n::locale(Lang::En))
.unwrap();
assert!(!paths.dictionaries_dir().join(DICTIONARIES_README).exists());
}
#[test]
fn a_deleted_invitation_stays_deleted() {
let dir = tempfile::tempdir().unwrap();
let paths = fresh_root(&dir);
let loc = crate::shared::i18n::locale(Lang::En);
paths.ensure_dirs(loc).unwrap();
let readme = paths.dictionaries_dir().join(DICTIONARIES_README);
std::fs::remove_file(&readme).unwrap();
paths.ensure_dirs(loc).unwrap();
assert!(!readme.exists());
}
}