use anyhow::{Context, Result};
use serde::Deserialize;
use std::path::{Path, PathBuf};
const DEFAULT_DB_FILENAME: &str = "rs-histver.redb";
#[derive(Debug, Deserialize, Clone)]
pub struct Config {
pub database: DatabaseConfig,
pub network: NetworkConfig,
}
#[derive(Debug, Deserialize, Clone)]
pub struct DatabaseConfig {
#[serde(default)]
pub path: String,
#[serde(default = "default_table_name")]
pub table_name: String,
}
#[derive(Debug, Deserialize, Clone)]
pub struct NetworkConfig {
#[serde(default = "default_timeout")]
pub timeout: u64,
#[serde(default = "default_max_concurrency")]
pub max_concurrency: usize,
#[serde(default = "default_user_agent")]
pub user_agent: String,
}
fn default_table_name() -> String {
"rust_releases".to_string()
}
fn default_timeout() -> u64 {
15
}
fn default_max_concurrency() -> usize {
10
}
fn default_user_agent() -> String {
"rs-histver/0.1".to_string()
}
impl Default for Config {
fn default() -> Self {
Self {
database: DatabaseConfig {
path: String::new(),
table_name: default_table_name(),
},
network: NetworkConfig {
timeout: default_timeout(),
max_concurrency: default_max_concurrency(),
user_agent: default_user_agent(),
},
}
}
}
impl Config {
pub fn load(path: &std::path::Path) -> Result<Self> {
if !path.exists() {
return Ok(Self::default());
}
let content = std::fs::read_to_string(path)
.with_context(|| format!("Failed to read config file: {}", path.display()))?;
let config: Config = toml::from_str(&content)
.with_context(|| format!("Failed to parse config file: {}", path.display()))?;
Ok(config)
}
pub fn resolve_path(cli_path: Option<&str>) -> PathBuf {
if let Some(p) = cli_path {
return PathBuf::from(p);
}
let cwd = PathBuf::from("config.toml");
if cwd.exists() {
return cwd;
}
if let Ok(exe) = std::env::current_exe() {
if let Some(dir) = exe.parent() {
let p = dir.join("config.toml");
if p.exists() {
return p;
}
}
}
PathBuf::from("config.toml")
}
pub fn db_path(&self) -> Result<PathBuf> {
if !self.database.path.is_empty() {
let p = PathBuf::from(&self.database.path);
if p.is_absolute() {
return Ok(p);
}
let exe_dir = std::env::current_exe()
.context("Failed to get executable path")?
.parent()
.context("Executable path has no parent directory")?
.to_path_buf();
return Ok(exe_dir.join(p));
}
let dir = dirs_data_dir()?;
Ok(dir.join("rs-histver").join(DEFAULT_DB_FILENAME))
}
#[allow(dead_code)] pub fn co_locate(
target_config_path: Option<&str>,
target_db_path: Option<&str>,
fallback_dir: Option<&str>,
) -> Self {
if let Some(cfg_path) = target_config_path {
if let Some(dir) = scan_target_config_for_db_dir(cfg_path) {
return Self::with_db_path(dir.join(DEFAULT_DB_FILENAME));
}
}
if let Some(db_path) = target_db_path {
let p = Path::new(db_path);
let dir = if p.is_dir() { p.to_path_buf() } else { p.parent().map(|d| d.to_path_buf()).unwrap_or_else(|| PathBuf::from(".")) };
return Self::with_db_path(dir.join(DEFAULT_DB_FILENAME));
}
if let Some(dir) = fallback_dir {
return Self::with_db_path(PathBuf::from(dir).join(DEFAULT_DB_FILENAME));
}
Self::default()
}
#[allow(dead_code)]
fn with_db_path(db_path: impl Into<PathBuf>) -> Self {
Self {
database: DatabaseConfig {
path: db_path.into().to_string_lossy().to_string(),
table_name: default_table_name(),
},
network: NetworkConfig {
timeout: default_timeout(),
max_concurrency: default_max_concurrency(),
user_agent: default_user_agent(),
},
}
}
}
#[allow(dead_code)]
fn scan_target_config_for_db_dir(cfg_path: &str) -> Option<PathBuf> {
let content = std::fs::read_to_string(cfg_path).ok()?;
scan_toml_for_db_dir(&content)
}
#[allow(dead_code)]
fn scan_toml_for_db_dir(content: &str) -> Option<PathBuf> {
let value: toml::Value = content.parse().ok()?;
if let Some(db_path) = value
.get("database")
.and_then(|db| db.get("path"))
.and_then(|v| v.as_str())
{
if !db_path.is_empty() {
return dir_of(db_path);
}
}
if let Some(url) = value.get("database_url").and_then(|v| v.as_str()) {
if let Some(db_path) = extract_file_path_from_db_url(url) {
return dir_of(&db_path);
}
}
if let Some(db_path) = value.get("db_path").and_then(|v| v.as_str()) {
if !db_path.is_empty() {
return dir_of(db_path);
}
}
None
}
#[allow(dead_code)]
fn extract_file_path_from_db_url(url: &str) -> Option<String> {
if let Some(path) = url.strip_prefix("sqlite://") {
if !path.is_empty() {
return Some(path.to_string());
}
}
if let Some(path) = url.strip_prefix("sqlite:") {
if !path.is_empty() {
return Some(path.to_string());
}
}
None
}
#[allow(dead_code)]
fn dir_of(db_path: &str) -> Option<PathBuf> {
let p = Path::new(db_path);
if p.exists() && p.is_dir() {
return Some(p.to_path_buf());
}
match p.parent() {
Some(parent) if !parent.as_os_str().is_empty() => Some(parent.to_path_buf()),
_ => Some(PathBuf::from(".")),
}
}
fn dirs_data_dir() -> Result<PathBuf> {
if cfg!(target_os = "windows") {
std::env::var("LOCALAPPDATA")
.map(PathBuf::from)
.context("LOCALAPPDATA environment variable not set")
} else {
dirs_home_dir().map(|h| h.join(".local").join("share"))
}
}
fn dirs_home_dir() -> Result<PathBuf> {
std::env::var("HOME")
.or_else(|_| std::env::var("USERPROFILE"))
.map(PathBuf::from)
.context("Failed to determine home directory")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_scan_toml_database_path() {
let dir = scan_toml_for_db_dir(r#"[database]
path = "data/project.db"
"#);
assert_eq!(dir, Some(PathBuf::from("data")));
}
#[test]
fn test_scan_toml_database_url() {
let dir = scan_toml_for_db_dir(r#"database_url = "sqlite://./data/project.db"
"#);
assert_eq!(dir, Some(PathBuf::from("./data")));
}
#[test]
fn test_scan_toml_db_path_key() {
let dir = scan_toml_for_db_dir(r#"db_path = "./data/project.db"
"#);
assert_eq!(dir, Some(PathBuf::from("./data")));
}
#[test]
fn test_scan_toml_empty_path() {
let dir = scan_toml_for_db_dir(r#"[database]
path = ""
"#);
assert!(dir.is_none());
}
#[test]
fn test_scan_toml_no_match() {
let dir = scan_toml_for_db_dir(r#"[app]
name = "hello"
"#);
assert!(dir.is_none());
}
#[test]
fn test_scan_toml_database_url_sqlite() {
let url = extract_file_path_from_db_url("sqlite://./data/project.db");
assert_eq!(url, Some("./data/project.db".to_string()));
}
#[test]
fn test_scan_toml_database_url_sqlite_no_slash() {
let url = extract_file_path_from_db_url("sqlite:./data/project.db");
assert_eq!(url, Some("./data/project.db".to_string()));
}
#[test]
fn test_dir_of_file() {
let dir = dir_of("data/project.db").unwrap();
assert_eq!(dir, PathBuf::from("data"));
}
#[test]
fn test_dir_of_directory() {
let dir = dir_of("data").unwrap();
assert_eq!(dir, PathBuf::from("."));
}
#[test]
fn test_co_locate_with_target_db_path() {
let config = Config::co_locate(None, Some("data/project.db"), None);
let expected = PathBuf::from("data/rs-histver.redb");
let got = std::path::Path::new(&config.database.path);
let expected = expected.iter().collect::<Vec<_>>();
let got = got.iter().collect::<Vec<_>>();
assert_eq!(got, expected);
}
#[test]
fn test_co_locate_with_fallback_dir() {
let config = Config::co_locate(None, None, Some("data"));
let expected = PathBuf::from("data/rs-histver.redb");
let got = std::path::Path::new(&config.database.path);
let expected = expected.iter().collect::<Vec<_>>();
let got = got.iter().collect::<Vec<_>>();
assert_eq!(got, expected);
}
#[test]
fn test_co_locate_with_none_defaults() {
let config = Config::co_locate(None, None, None);
assert!(config.database.path.is_empty());
assert_eq!(config.database.table_name, "rust_releases");
assert_eq!(config.network.timeout, 15);
}
}