use crate::{Error, Result, profile};
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::{Path, PathBuf};
const fn default_filter_non_english() -> bool {
true
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Config {
pub defaults: DefaultsConfig,
pub paths: PathsConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DefaultsConfig {
pub refresh_hours: u32,
pub max_archives: usize,
pub fetch_enabled: bool,
pub follow_links: FollowLinks,
pub allowlist: Vec<String>,
#[serde(default = "default_filter_non_english")]
pub filter_non_english: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum FollowLinks {
None,
FirstParty,
Allowlist,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PathsConfig {
pub root: PathBuf,
}
impl Config {
pub fn load() -> Result<Self> {
let base_path = Self::existing_config_path()?;
let mut base_value: toml::Value = if let Some(ref path) = base_path {
let content = fs::read_to_string(path)
.map_err(|e| Error::Config(format!("Failed to read config: {e}")))?;
toml::from_str(&content)
.map_err(|e| Error::Config(format!("Failed to parse config: {e}")))?
} else {
let default_str = toml::to_string(&Self::default())
.map_err(|e| Error::Config(format!("Failed to init default config: {e}")))?;
toml::from_str(&default_str)
.map_err(|e| Error::Config(format!("Failed to init default config: {e}")))?
};
let base_dir = base_path.as_deref().map_or_else(
|| {
Self::canonical_config_path().map_or_else(
|_| PathBuf::new(),
|p| p.parent().map(Path::to_path_buf).unwrap_or_default(),
)
},
|bp| bp.parent().map(Path::to_path_buf).unwrap_or_default(),
);
let local_path = base_dir.join("config.local.toml");
if local_path.exists() {
let content = fs::read_to_string(&local_path)
.map_err(|e| Error::Config(format!("Failed to read local config: {e}")))?;
let local_value: toml::Value = toml::from_str(&content)
.map_err(|e| Error::Config(format!("Failed to parse local config: {e}")))?;
Self::merge_toml(&mut base_value, &local_value);
}
let mut config: Self = base_value
.try_into()
.map_err(|e| Error::Config(format!("Failed to materialize config: {e}")))?;
config.apply_env_overrides();
Ok(config)
}
pub fn save(&self) -> Result<()> {
let config_path = Self::save_target_path()?;
let parent = config_path
.parent()
.ok_or_else(|| Error::Config("Invalid config path".into()))?;
fs::create_dir_all(parent)
.map_err(|e| Error::Config(format!("Failed to create config directory: {e}")))?;
let content = toml::to_string_pretty(self)
.map_err(|e| Error::Config(format!("Failed to serialize config: {e}")))?;
let tmp = parent.join("config.toml.tmp");
fs::write(&tmp, &content)
.map_err(|e| Error::Config(format!("Failed to write temp config: {e}")))?;
#[cfg(target_os = "windows")]
if config_path.exists() {
fs::remove_file(&config_path)
.map_err(|e| Error::Config(format!("Failed to remove existing config: {e}")))?;
}
std::fs::rename(&tmp, &config_path)
.map_err(|e| Error::Config(format!("Failed to replace config: {e}")))?;
Ok(())
}
fn canonical_config_path() -> Result<PathBuf> {
let xdg = std::env::var("XDG_CONFIG_HOME")
.ok()
.map(PathBuf::from)
.or_else(|| directories::BaseDirs::new().map(|b| b.home_dir().join(".config")))
.ok_or_else(|| Error::Config("Failed to determine XDG config directory".into()))?;
Ok(xdg.join(profile::app_dir_slug()).join("config.toml"))
}
fn dotfile_config_path() -> Result<PathBuf> {
let home = directories::BaseDirs::new()
.map(|b| b.home_dir().to_path_buf())
.ok_or_else(|| Error::Config("Failed to determine home directory".into()))?;
Ok(home.join(profile::dot_dir_slug()).join("config.toml"))
}
fn existing_config_path() -> Result<Option<PathBuf>> {
if let Ok(explicit) = std::env::var("BLZ_CONFIG") {
let explicit = explicit.trim();
if !explicit.is_empty() {
let p = PathBuf::from(explicit);
if p.is_file() && p.exists() {
return Ok(Some(p));
}
}
}
if let Ok(dir) = std::env::var("BLZ_CONFIG_DIR") {
let dir = dir.trim();
if !dir.is_empty() {
let p = PathBuf::from(dir).join("config.toml");
if p.is_file() && p.exists() {
return Ok(Some(p));
}
}
}
let xdg = Self::canonical_config_path()?;
if xdg.exists() {
return Ok(Some(xdg));
}
let dot = Self::dotfile_config_path()?;
if dot.exists() {
return Ok(Some(dot));
}
Ok(None)
}
fn save_target_path() -> Result<PathBuf> {
if let Some(existing) = Self::existing_config_path()? {
return Ok(existing);
}
Self::canonical_config_path()
}
fn merge_toml(dst: &mut toml::Value, src: &toml::Value) {
use toml::Value::Table;
match (dst, src) {
(Table(dst_tbl), Table(src_tbl)) => {
for (k, v) in src_tbl {
match dst_tbl.get_mut(k) {
Some(dst_v) => Self::merge_toml(dst_v, v),
None => {
dst_tbl.insert(k.clone(), v.clone());
},
}
}
},
(dst_v, src_v) => *dst_v = src_v.clone(),
}
}
fn apply_env_overrides(&mut self) {
if let Ok(v) = std::env::var("BLZ_REFRESH_HOURS") {
if let Ok(n) = v.parse::<u32>() {
self.defaults.refresh_hours = n;
}
}
if let Ok(v) = std::env::var("BLZ_MAX_ARCHIVES") {
if let Ok(n) = v.parse::<usize>() {
self.defaults.max_archives = n;
}
}
if let Ok(v) = std::env::var("BLZ_FETCH_ENABLED") {
let norm = v.to_ascii_lowercase();
self.defaults.fetch_enabled = matches!(norm.as_str(), "1" | "true" | "yes" | "on");
}
if let Ok(v) = std::env::var("BLZ_FOLLOW_LINKS") {
match v.to_ascii_lowercase().as_str() {
"none" => self.defaults.follow_links = FollowLinks::None,
"first_party" | "firstparty" => {
self.defaults.follow_links = FollowLinks::FirstParty;
},
"allowlist" => self.defaults.follow_links = FollowLinks::Allowlist,
_ => {},
}
}
if let Ok(v) = std::env::var("BLZ_ALLOWLIST") {
let list = v
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect::<Vec<_>>();
if !list.is_empty() {
self.defaults.allowlist = list;
}
}
if let Ok(v) = std::env::var("BLZ_ROOT") {
let p = PathBuf::from(v);
if !p.as_os_str().is_empty() {
self.paths.root = p;
}
}
}
}
impl Default for Config {
fn default() -> Self {
Self {
defaults: DefaultsConfig {
refresh_hours: 24,
max_archives: 10,
fetch_enabled: true,
follow_links: FollowLinks::FirstParty,
allowlist: Vec::new(),
filter_non_english: true,
},
paths: PathsConfig {
root: directories::ProjectDirs::from("dev", "outfitter", profile::app_dir_slug())
.map_or_else(
|| {
directories::BaseDirs::new().map_or_else(
|| PathBuf::from(".outfitter").join(profile::app_dir_slug()),
|base| {
base.home_dir()
.join(".outfitter")
.join(profile::app_dir_slug())
},
)
},
|dirs| dirs.data_dir().to_path_buf(),
),
},
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolConfig {
pub meta: ToolMeta,
pub fetch: FetchConfig,
pub index: IndexConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolMeta {
pub name: String,
pub display_name: Option<String>,
pub homepage: Option<String>,
pub repo: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FetchConfig {
pub refresh_hours: Option<u32>,
pub follow_links: Option<FollowLinks>,
pub allowlist: Option<Vec<String>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IndexConfig {
pub max_heading_block_lines: Option<usize>,
pub filter_non_english: Option<bool>,
}
impl ToolConfig {
pub fn load(path: &Path) -> Result<Self> {
let content = fs::read_to_string(path)
.map_err(|e| Error::Config(format!("Failed to read tool config: {e}")))?;
toml::from_str(&content)
.map_err(|e| Error::Config(format!("Failed to parse tool config: {e}")))
}
pub fn save(&self, path: &Path) -> Result<()> {
let content = toml::to_string_pretty(self)
.map_err(|e| Error::Config(format!("Failed to serialize tool config: {e}")))?;
fs::write(path, content)
.map_err(|e| Error::Config(format!("Failed to write tool config: {e}")))?;
Ok(())
}
}
#[cfg(test)]
#[allow(
clippy::panic,
clippy::disallowed_macros,
clippy::unwrap_used,
clippy::unnecessary_wraps
)]
mod tests {
use super::*;
use proptest::prelude::*;
use std::fs;
use tempfile::TempDir;
fn create_test_config() -> Config {
Config {
defaults: DefaultsConfig {
refresh_hours: 12,
max_archives: 5,
fetch_enabled: true,
follow_links: FollowLinks::Allowlist,
allowlist: vec!["example.com".to_string(), "docs.rs".to_string()],
filter_non_english: true,
},
paths: PathsConfig {
root: PathBuf::from("/tmp/test"),
},
}
}
fn create_test_tool_config() -> ToolConfig {
ToolConfig {
meta: ToolMeta {
name: "test-tool".to_string(),
display_name: Some("Test Tool".to_string()),
homepage: Some("https://test.com".to_string()),
repo: Some("https://github.com/test/tool".to_string()),
},
fetch: FetchConfig {
refresh_hours: Some(6),
follow_links: Some(FollowLinks::FirstParty),
allowlist: Some(vec!["allowed.com".to_string()]),
},
index: IndexConfig {
max_heading_block_lines: Some(100),
filter_non_english: None,
},
}
}
#[test]
fn test_default_config_values() {
let config = Config::default();
assert_eq!(config.defaults.refresh_hours, 24);
assert_eq!(config.defaults.max_archives, 10);
assert!(config.defaults.fetch_enabled);
assert!(matches!(
config.defaults.follow_links,
FollowLinks::FirstParty
));
assert!(config.defaults.allowlist.is_empty());
assert!(config.defaults.filter_non_english);
assert!(!config.paths.root.as_os_str().is_empty());
}
#[test]
fn test_follow_links_serialization() -> Result<()> {
let variants = vec![
FollowLinks::None,
FollowLinks::FirstParty,
FollowLinks::Allowlist,
];
for variant in variants {
let serialized = serde_json::to_string(&variant)?;
let deserialized: FollowLinks = serde_json::from_str(&serialized)?;
assert_eq!(variant, deserialized, "Round-trip failed for {variant:?}");
}
Ok(())
}
#[test]
fn test_config_save_and_load_roundtrip() -> Result<()> {
let temp_dir = TempDir::new().map_err(|e| Error::Config(e.to_string()))?;
let config_path = temp_dir.path().join("test_config.toml");
let original_config = create_test_config();
let content = toml::to_string_pretty(&original_config)
.map_err(|e| Error::Config(format!("Failed to serialize: {e}")))?;
fs::write(&config_path, content)
.map_err(|e| Error::Config(format!("Failed to write: {e}")))?;
let loaded_config: Config = {
let content = fs::read_to_string(&config_path)
.map_err(|e| Error::Config(format!("Failed to read: {e}")))?;
toml::from_str(&content).map_err(|e| Error::Config(format!("Failed to parse: {e}")))?
};
assert_eq!(
loaded_config.defaults.refresh_hours,
original_config.defaults.refresh_hours
);
assert_eq!(
loaded_config.defaults.max_archives,
original_config.defaults.max_archives
);
assert_eq!(
loaded_config.defaults.fetch_enabled,
original_config.defaults.fetch_enabled
);
assert_eq!(
loaded_config.defaults.allowlist,
original_config.defaults.allowlist
);
assert_eq!(loaded_config.paths.root, original_config.paths.root);
Ok(())
}
#[test]
fn test_config_load_missing_file() {
let non_existent = PathBuf::from("/definitely/does/not/exist/config.toml");
let result = (|| -> Result<Config> {
let content = fs::read_to_string(&non_existent)
.map_err(|e| Error::Config(format!("Failed to read config: {e}")))?;
toml::from_str(&content)
.map_err(|e| Error::Config(format!("Failed to parse config: {e}")))
})();
assert!(result.is_err());
match result {
Err(Error::Config(msg)) => assert!(msg.contains("Failed to read config")),
_ => unreachable!("Expected Config error"),
}
}
#[test]
fn test_config_parse_invalid_toml() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let config_path = temp_dir.path().join("invalid.toml");
fs::write(&config_path, "this is not valid toml [[[").expect("Failed to write test file");
let result = (|| -> Result<Config> {
let content = fs::read_to_string(&config_path)
.map_err(|e| Error::Config(format!("Failed to read config: {e}")))?;
toml::from_str(&content)
.map_err(|e| Error::Config(format!("Failed to parse config: {e}")))
})();
assert!(result.is_err());
if let Err(Error::Config(msg)) = result {
assert!(msg.contains("Failed to parse config"));
} else {
panic!("Expected Config parse error");
}
}
#[test]
fn test_config_save_creates_directory() -> Result<()> {
let temp_dir = TempDir::new().map_err(|e| Error::Config(e.to_string()))?;
let nested_path = temp_dir
.path()
.join("nested")
.join("deeper")
.join("config.toml");
let config = create_test_config();
let parent = nested_path
.parent()
.ok_or_else(|| Error::Config("Invalid config path".into()))?;
fs::create_dir_all(parent)
.map_err(|e| Error::Config(format!("Failed to create config directory: {e}")))?;
let content = toml::to_string_pretty(&config)
.map_err(|e| Error::Config(format!("Failed to serialize config: {e}")))?;
fs::write(&nested_path, content)
.map_err(|e| Error::Config(format!("Failed to write config: {e}")))?;
assert!(nested_path.exists());
assert!(
nested_path
.parent()
.expect("path should have parent")
.exists()
);
Ok(())
}
#[test]
fn test_tool_config_roundtrip() -> Result<()> {
let temp_dir = TempDir::new().map_err(|e| Error::Config(e.to_string()))?;
let config_path = temp_dir.path().join("tool.toml");
let original_config = create_test_tool_config();
original_config.save(&config_path)?;
let loaded_config = ToolConfig::load(&config_path)?;
assert_eq!(loaded_config.meta.name, original_config.meta.name);
assert_eq!(
loaded_config.meta.display_name,
original_config.meta.display_name
);
assert_eq!(loaded_config.meta.homepage, original_config.meta.homepage);
assert_eq!(loaded_config.meta.repo, original_config.meta.repo);
assert_eq!(
loaded_config.fetch.refresh_hours,
original_config.fetch.refresh_hours
);
assert_eq!(
loaded_config.fetch.allowlist,
original_config.fetch.allowlist
);
assert_eq!(
loaded_config.index.max_heading_block_lines,
original_config.index.max_heading_block_lines
);
Ok(())
}
#[test]
fn test_tool_config_load_nonexistent_file() {
let non_existent = PathBuf::from("/does/not/exist/tool.toml");
let result = ToolConfig::load(&non_existent);
assert!(result.is_err());
if let Err(Error::Config(msg)) = result {
assert!(msg.contains("Failed to read tool config"));
} else {
panic!("Expected Config error");
}
}
#[test]
fn test_config_with_extreme_values() -> Result<()> {
let extreme_config = Config {
defaults: DefaultsConfig {
refresh_hours: 1_000_000, max_archives: 1_000_000, fetch_enabled: false,
follow_links: FollowLinks::None,
allowlist: vec!["a".repeat(1000)], filter_non_english: false,
},
paths: PathsConfig {
root: PathBuf::from("/".repeat(100)), },
};
let serialized = toml::to_string_pretty(&extreme_config)
.map_err(|e| Error::Config(format!("Serialize failed: {e}")))?;
let deserialized: Config = toml::from_str(&serialized)
.map_err(|e| Error::Config(format!("Deserialize failed: {e}")))?;
assert_eq!(deserialized.defaults.refresh_hours, 1_000_000);
assert_eq!(deserialized.defaults.max_archives, 1_000_000);
assert!(!deserialized.defaults.fetch_enabled);
assert_eq!(deserialized.defaults.allowlist.len(), 1);
assert_eq!(deserialized.defaults.allowlist[0].len(), 1000);
Ok(())
}
#[test]
fn test_config_empty_allowlist() -> Result<()> {
let config = Config {
defaults: DefaultsConfig {
refresh_hours: 24,
max_archives: 10,
fetch_enabled: true,
follow_links: FollowLinks::Allowlist,
allowlist: vec![], filter_non_english: true,
},
paths: PathsConfig {
root: PathBuf::from("/tmp"),
},
};
let serialized = toml::to_string_pretty(&config)?;
let deserialized: Config = toml::from_str(&serialized)?;
assert!(deserialized.defaults.allowlist.is_empty());
assert!(matches!(
deserialized.defaults.follow_links,
FollowLinks::Allowlist
));
Ok(())
}
#[test]
fn test_defaults_config_backward_compatibility_filter_non_english() -> Result<()> {
let toml_without_filter = r#"
[defaults]
refresh_hours = 24
max_archives = 10
fetch_enabled = true
follow_links = "first_party"
allowlist = []
[paths]
root = "/tmp/test"
"#;
let config: Config = toml::from_str(toml_without_filter)
.map_err(|e| Error::Config(format!("Failed to parse: {e}")))?;
assert!(config.defaults.filter_non_english);
assert_eq!(config.defaults.refresh_hours, 24);
Ok(())
}
#[test]
fn test_index_config_backward_compatibility_filter_non_english() -> Result<()> {
let config = IndexConfig {
max_heading_block_lines: Some(500),
filter_non_english: None,
};
let serialized = serde_json::to_string(&config)
.map_err(|e| Error::Config(format!("Failed to serialize: {e}")))?;
let deserialized: IndexConfig = serde_json::from_str(&serialized)
.map_err(|e| Error::Config(format!("Failed to deserialize: {e}")))?;
assert_eq!(deserialized.filter_non_english, None);
assert_eq!(deserialized.max_heading_block_lines, Some(500));
Ok(())
}
#[test]
fn test_filter_non_english_serialization() -> Result<()> {
let config = Config {
defaults: DefaultsConfig {
refresh_hours: 24,
max_archives: 10,
fetch_enabled: true,
follow_links: FollowLinks::FirstParty,
allowlist: vec![],
filter_non_english: false,
},
paths: PathsConfig {
root: PathBuf::from("/tmp"),
},
};
let serialized = toml::to_string_pretty(&config)?;
let deserialized: Config = toml::from_str(&serialized)?;
assert!(!deserialized.defaults.filter_non_english);
Ok(())
}
proptest! {
#[test]
fn test_config_refresh_hours_roundtrip(refresh_hours in 1u32..=365*24) {
let config = Config {
defaults: DefaultsConfig {
refresh_hours,
max_archives: 10,
fetch_enabled: true,
follow_links: FollowLinks::FirstParty,
allowlist: vec![],
filter_non_english: true,
},
paths: PathsConfig {
root: PathBuf::from("/tmp"),
},
};
let serialized = toml::to_string_pretty(&config).expect("should serialize");
let deserialized: Config = toml::from_str(&serialized).expect("should deserialize");
prop_assert_eq!(deserialized.defaults.refresh_hours, refresh_hours);
}
#[test]
fn test_config_max_archives_roundtrip(max_archives in 1usize..=1000) {
let config = Config {
defaults: DefaultsConfig {
refresh_hours: 24,
max_archives,
fetch_enabled: true,
follow_links: FollowLinks::FirstParty,
allowlist: vec![],
filter_non_english: true,
},
paths: PathsConfig {
root: PathBuf::from("/tmp"),
},
};
let serialized = toml::to_string_pretty(&config).expect("should serialize");
let deserialized: Config = toml::from_str(&serialized).expect("should deserialize");
prop_assert_eq!(deserialized.defaults.max_archives, max_archives);
}
#[test]
fn test_config_allowlist_roundtrip(allowlist in prop::collection::vec(r"[a-z0-9\.-]+", 0..=10)) {
let config = Config {
defaults: DefaultsConfig {
refresh_hours: 24,
max_archives: 10,
fetch_enabled: true,
follow_links: FollowLinks::Allowlist,
allowlist: allowlist.clone(),
filter_non_english: true,
},
paths: PathsConfig {
root: PathBuf::from("/tmp"),
},
};
let serialized = toml::to_string_pretty(&config).expect("should serialize");
let deserialized: Config = toml::from_str(&serialized).expect("should deserialize");
prop_assert_eq!(deserialized.defaults.allowlist, allowlist);
}
}
}