use crate::canonical_config::CanonicalConfig;
use crate::errors::CoreError;
use crate::paths;
use serde::Deserialize;
use std::collections::HashMap;
use std::path::Path;
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
struct OnDiskConfig {
#[serde(default)]
canonical: CanonicalSection,
}
#[derive(Debug, Default, Deserialize)]
struct CanonicalSection {
#[serde(default)]
domains: HashMap<String, DomainSection>,
}
#[derive(Debug, Default, Deserialize)]
struct DomainSection {
#[serde(default)]
preserve_params: Vec<String>,
}
pub fn load() -> Result<CanonicalConfig, CoreError> {
let path = paths::linkmarks_config_path();
load_from(&path)
}
pub fn load_from(path: &Path) -> Result<CanonicalConfig, CoreError> {
if !path.exists() {
return Ok(CanonicalConfig::default_rules());
}
let text = std::fs::read_to_string(path)
.map_err(|e| CoreError::Storage(format!("read config {}: {e}", path.display())))?;
parse(&text, path)
}
fn parse(text: &str, origin: &Path) -> Result<CanonicalConfig, CoreError> {
if text.trim().is_empty() {
return Ok(CanonicalConfig::default_rules());
}
let on_disk: OnDiskConfig = toml::from_str(text)
.map_err(|e| CoreError::Storage(format!("parse config {}: {e}", origin.display())))?;
let mut cfg = CanonicalConfig::default_rules();
for (host, dom) in on_disk.canonical.domains {
let host_lc = host.to_ascii_lowercase();
cfg.domains.insert(
host_lc,
crate::canonical_config::DomainRules {
preserve_params: dom
.preserve_params
.into_iter()
.map(|p| p.to_ascii_lowercase())
.collect(),
},
);
}
Ok(cfg)
}
pub fn ensure_default() -> Result<bool, CoreError> {
let path = paths::linkmarks_config_path();
if path.exists() {
return Ok(false);
}
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&path, DEFAULT_CONFIG_BODY)
.map_err(|e| CoreError::Storage(format!("write default config: {e}")))?;
Ok(true)
}
pub const DEFAULT_CONFIG_BODY: &str = r#"# LinkMarks configuration
# Placed at ${XDG_CONFIG_HOME:-~/.config}/linkmarks/config.toml
# Per-domain canonicalization overrides. Parameters in this list are
# preserved across canonicalization. Parameters in the global tracking
# blocklist (utm_*, fbclid, gclid, etc.) are still dropped unless
# listed here.
[canonical.domains]
"youtube.com" = { preserve_params = ["t", "v", "list", "index", "si"] }
"youtu.be" = { preserve_params = ["t", "si"] }
"vimeo.com" = { preserve_params = ["t"] }
"github.com" = { preserve_params = ["q", "tab", "type"] }
"twitter.com" = { preserve_params = ["s", "src"] }
"x.com" = { preserve_params = ["s", "src"] }
"amazon.com" = { preserve_params = ["tag"] }
"#;
#[cfg(test)]
mod tests {
use super::*;
use crate::canonical_config::{CanonicalConfig, DomainRules};
#[test]
fn missing_file_returns_defaults() {
let cfg = load_from(Path::new("/nonexistent/config.toml")).unwrap();
assert_eq!(cfg.domains.len(), 0);
assert!(matches!(cfg, CanonicalConfig { .. }));
}
#[test]
fn empty_file_returns_defaults() {
let cfg = parse("", Path::new("/dev/null")).unwrap();
assert_eq!(cfg.domains.len(), 0);
}
#[test]
fn whitespace_only_file_returns_defaults() {
let cfg = parse(" \n\t \n", Path::new("/dev/null")).unwrap();
assert_eq!(cfg.domains.len(), 0);
}
#[test]
fn invalid_toml_returns_storage_error_with_path() {
let err = parse(
"this is = not valid toml [[[",
Path::new("/tmp/broken-config.toml"),
)
.unwrap_err();
match err {
CoreError::Storage(msg) => {
assert!(msg.contains("/tmp/broken-config.toml"), "got: {msg}");
}
other => panic!("expected Storage, got: {other:?}"),
}
}
#[test]
fn per_domain_overrides_merge_over_defaults() {
let toml = r#"
[canonical.domains]
"amazon.com" = { preserve_params = ["tag", "ref"] }
"#;
let cfg = parse(toml, Path::new("/dev/null")).unwrap();
assert!(cfg.is_preserved("amazon.com", "tag"));
assert!(cfg.is_preserved("amazon.com", "ref"));
assert!(!cfg.is_preserved("amazon.com", "nope"));
}
#[test]
fn always_functional_wins_over_dropped_list() {
let toml = r#"
[canonical.domains]
"example.com" = { preserve_params = ["ref"] }
"#;
let cfg = parse(toml, Path::new("/dev/null")).unwrap();
assert!(cfg.is_preserved("any-host", "id"));
assert!(cfg.is_preserved("any-host", "page"));
assert!(cfg.is_preserved("any-host", "q"));
assert!(cfg.is_preserved("example.com", "ref"));
assert!(!cfg.is_preserved("other.com", "ref"));
}
#[test]
fn host_lowercased_on_parse() {
let toml = r#"
[canonical.domains]
"AMAZON.COM" = { preserve_params = ["tag"] }
"#;
let cfg = parse(toml, Path::new("/dev/null")).unwrap();
assert!(cfg.domains.contains_key("amazon.com"));
assert!(!cfg.domains.contains_key("AMAZON.COM"));
}
#[test]
fn param_names_lowercased_on_parse() {
let toml = r#"
[canonical.domains]
"amazon.com" = { preserve_params = ["TAG", "Ref"] }
"#;
let cfg = parse(toml, Path::new("/dev/null")).unwrap();
let rules = cfg.domains.get("amazon.com").expect("host present");
assert_eq!(
rules.preserve_params,
vec!["tag".to_string(), "ref".to_string()]
);
}
#[test]
fn unknown_top_level_section_rejected() {
let toml = "[cannonical.domains]\n\"x.com\" = { preserve_params = [\"a\"] }\n";
let err = parse(toml, Path::new("/dev/null")).unwrap_err();
match err {
CoreError::Storage(msg) => assert!(msg.contains("cannonical"), "got: {msg}"),
other => panic!("expected Storage, got: {other:?}"),
}
}
#[test]
fn ensure_default_is_idempotent() {
let parsed = parse(DEFAULT_CONFIG_BODY, Path::new("/dev/null")).unwrap();
assert!(parsed.is_preserved("youtube.com", "t"));
assert!(parsed.is_preserved("github.com", "q"));
assert!(parsed.is_preserved("amazon.com", "tag"));
let _ = DomainRules::default();
}
}