use anyhow::{Context, Result, bail};
use directories::ProjectDirs;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::fs;
use std::path::PathBuf;
#[derive(Debug, Serialize, Deserialize)]
pub struct Config {
pub remote: String,
pub cache_max_bytes: u64,
pub openers: BTreeMap<String, String>,
#[serde(skip)]
pub data_dir: PathBuf,
}
impl Default for Config {
fn default() -> Self {
let mut openers = BTreeMap::new();
openers.insert("pdf".to_string(), "okular".to_string());
Config {
remote: String::new(),
cache_max_bytes: 20 * 1024 * 1024 * 1024,
openers,
data_dir: PathBuf::new(),
}
}
}
fn project_dirs() -> Result<ProjectDirs> {
ProjectDirs::from("", "", "doclib").context("cannot determine platform config directories")
}
pub fn config_path() -> Result<PathBuf> {
Ok(project_dirs()?.config_dir().join("config.toml"))
}
impl Config {
pub fn load() -> Result<Config> {
let dirs = project_dirs()?;
let config_file = dirs.config_dir().join("config.toml");
let data_dir = dirs.data_dir().to_path_buf();
fs::create_dir_all(dirs.config_dir())?;
fs::create_dir_all(&data_dir)?;
if !config_file.exists() {
let default = Config::default();
fs::write(&config_file, default.to_toml()?)?;
eprintln!("wrote default config to {}", config_file.display());
eprintln!("set `remote` before importing — see the comments in that file.");
}
let text = fs::read_to_string(&config_file)
.with_context(|| format!("reading {}", config_file.display()))?;
let mut cfg: Config =
toml::from_str(&text).with_context(|| format!("parsing {}", config_file.display()))?;
cfg.data_dir = data_dir;
Ok(cfg)
}
fn to_toml(&self) -> Result<String> {
let body = toml::to_string_pretty(self)?;
Ok(format!("{CONFIG_HEADER}\n{body}"))
}
pub fn validate_remote(&self) -> Result<()> {
let remote = self.remote.trim();
if remote.is_empty() {
bail!(
"no remote configured.\n\
set `remote` in {}\n\
list your configured rclone remotes with `rclone listremotes`.",
config_path()?.display()
);
}
if remote.starts_with(':') || remote.starts_with('/') {
return Ok(());
}
let head = remote.split('/').next().unwrap_or(remote);
if head.contains(':') {
return Ok(());
}
bail!(
"remote {remote:?} is a relative local path, so where files go depends on \
the directory you run doclib from.\n\
use a configured rclone remote (\"{remote}:doclib\", if that is the remote's \
name) or an absolute path (\"/home/you/{remote}\").\n\
edit {}",
config_path()?.display()
);
}
pub fn db_path(&self) -> PathBuf {
self.data_dir.join("catalog.db")
}
pub fn cache_dir(&self) -> PathBuf {
self.data_dir.join("cache")
}
pub fn cache_path(&self, hash: &str, ext: &str) -> PathBuf {
self.cache_dir()
.join(&hash[..2])
.join(format!("{hash}.{ext}"))
}
pub fn remote_path(&self, hash: &str, ext: &str) -> String {
format!("{}/{}.{}", &hash[..2], hash, ext)
}
}
const CONFIG_HEADER: &str = r#"# doclib configuration
#
# remote
# Where documents are stored, in rclone's own syntax. Run
# `rclone listremotes` to see what you have configured, and
# `rclone config` to add one. Any backend rclone supports works:
#
# remote = "gdrive:doclib" a configured remote named "gdrive"
# remote = "dropbox:books" any other configured remote
# remote = "b2:my-bucket/doclib" bucket storage, with a subpath
# remote = "/mnt/usb/doclib" an absolute local path, no remote needed
#
# A bare name with no colon ("doclib") is NOT a remote — rclone reads it as a
# relative local directory, so the destination would follow your shell's
# working directory. doclib refuses that.
#
# cache_max_bytes
# Local cache ceiling. `doclib cache prune` evicts least-recently-opened
# files above it; they are re-fetched from the remote on the next open.
# Set 0 to never evict.
#
# openers
# Per-extension launcher. Anything unlisted falls back to $DOCLIB_OPENER,
# then xdg-open.
"#;
#[cfg(test)]
mod tests {
use super::*;
fn with_remote(remote: &str) -> Config {
Config {
remote: remote.to_string(),
..Default::default()
}
}
#[test]
fn accepts_every_shape_of_configured_remote() {
for remote in [
"gdrive:doclib",
"dropbox:books",
"b2:my-bucket/doclib",
"onedrive:",
":s3:my-bucket/doclib",
"/mnt/usb/doclib",
"/home/someone/doclib",
] {
assert!(
with_remote(remote).validate_remote().is_ok(),
"rejected valid remote {remote:?}"
);
}
}
#[test]
fn rejects_a_relative_path_masquerading_as_a_remote() {
for remote in ["doclib", "books/doclib", "./doclib", "../shared"] {
assert!(
with_remote(remote).validate_remote().is_err(),
"accepted relative path {remote:?}"
);
}
}
#[test]
fn rejects_an_unset_remote() {
assert!(with_remote("").validate_remote().is_err());
assert!(with_remote(" ").validate_remote().is_err());
}
#[test]
fn the_shipped_default_does_not_validate() {
assert!(Config::default().validate_remote().is_err());
}
}