1use crate::canonical_config::CanonicalConfig;
21use crate::errors::CoreError;
22use crate::paths;
23use serde::Deserialize;
24use std::collections::HashMap;
25use std::path::Path;
26
27#[derive(Debug, Default, Deserialize)]
31#[serde(deny_unknown_fields)]
32struct OnDiskConfig {
33 #[serde(default)]
35 canonical: CanonicalSection,
36}
37
38#[derive(Debug, Default, Deserialize)]
39struct CanonicalSection {
40 #[serde(default)]
42 domains: HashMap<String, DomainSection>,
43}
44
45#[derive(Debug, Default, Deserialize)]
46struct DomainSection {
47 #[serde(default)]
49 preserve_params: Vec<String>,
50}
51
52pub fn load() -> Result<CanonicalConfig, CoreError> {
56 let path = paths::linkmarks_config_path();
57 load_from(&path)
58}
59
60pub fn load_from(path: &Path) -> Result<CanonicalConfig, CoreError> {
62 if !path.exists() {
63 return Ok(CanonicalConfig::default_rules());
64 }
65 let text = std::fs::read_to_string(path)
66 .map_err(|e| CoreError::Storage(format!("read config {}: {e}", path.display())))?;
67 parse(&text, path)
68}
69
70fn parse(text: &str, origin: &Path) -> Result<CanonicalConfig, CoreError> {
71 if text.trim().is_empty() {
72 return Ok(CanonicalConfig::default_rules());
73 }
74 let on_disk: OnDiskConfig = toml::from_str(text)
75 .map_err(|e| CoreError::Storage(format!("parse config {}: {e}", origin.display())))?;
76
77 let mut cfg = CanonicalConfig::default_rules();
78 for (host, dom) in on_disk.canonical.domains {
79 let host_lc = host.to_ascii_lowercase();
81 cfg.domains.insert(
82 host_lc,
83 crate::canonical_config::DomainRules {
84 preserve_params: dom
85 .preserve_params
86 .into_iter()
87 .map(|p| p.to_ascii_lowercase())
88 .collect(),
89 },
90 );
91 }
92 Ok(cfg)
93}
94
95pub fn ensure_default() -> Result<bool, CoreError> {
100 let path = paths::linkmarks_config_path();
101 if path.exists() {
102 return Ok(false);
103 }
104 if let Some(parent) = path.parent() {
105 std::fs::create_dir_all(parent)?;
106 }
107 std::fs::write(&path, DEFAULT_CONFIG_BODY)
108 .map_err(|e| CoreError::Storage(format!("write default config: {e}")))?;
109 Ok(true)
110}
111
112pub const DEFAULT_CONFIG_BODY: &str = r#"# LinkMarks configuration
115# Placed at ${XDG_CONFIG_HOME:-~/.config}/linkmarks/config.toml
116# Per-domain canonicalization overrides. Parameters in this list are
117# preserved across canonicalization. Parameters in the global tracking
118# blocklist (utm_*, fbclid, gclid, etc.) are still dropped unless
119# listed here.
120[canonical.domains]
121"youtube.com" = { preserve_params = ["t", "v", "list", "index", "si"] }
122"youtu.be" = { preserve_params = ["t", "si"] }
123"vimeo.com" = { preserve_params = ["t"] }
124"github.com" = { preserve_params = ["q", "tab", "type"] }
125"twitter.com" = { preserve_params = ["s", "src"] }
126"x.com" = { preserve_params = ["s", "src"] }
127"amazon.com" = { preserve_params = ["tag"] }
128"#;
129
130#[cfg(test)]
131mod tests {
132 use super::*;
133 use crate::canonical_config::{CanonicalConfig, DomainRules};
134
135 #[test]
136 fn missing_file_returns_defaults() {
137 let cfg = load_from(Path::new("/nonexistent/config.toml")).unwrap();
138 assert_eq!(cfg.domains.len(), 0);
139 assert!(matches!(cfg, CanonicalConfig { .. }));
140 }
141
142 #[test]
143 fn empty_file_returns_defaults() {
144 let cfg = parse("", Path::new("/dev/null")).unwrap();
145 assert_eq!(cfg.domains.len(), 0);
146 }
147
148 #[test]
149 fn whitespace_only_file_returns_defaults() {
150 let cfg = parse(" \n\t \n", Path::new("/dev/null")).unwrap();
151 assert_eq!(cfg.domains.len(), 0);
152 }
153
154 #[test]
155 fn invalid_toml_returns_storage_error_with_path() {
156 let err = parse(
157 "this is = not valid toml [[[",
158 Path::new("/tmp/broken-config.toml"),
159 )
160 .unwrap_err();
161 match err {
162 CoreError::Storage(msg) => {
163 assert!(msg.contains("/tmp/broken-config.toml"), "got: {msg}");
164 }
165 other => panic!("expected Storage, got: {other:?}"),
166 }
167 }
168
169 #[test]
170 fn per_domain_overrides_merge_over_defaults() {
171 let toml = r#"
172[canonical.domains]
173"amazon.com" = { preserve_params = ["tag", "ref"] }
174"#;
175 let cfg = parse(toml, Path::new("/dev/null")).unwrap();
176 assert!(cfg.is_preserved("amazon.com", "tag"));
177 assert!(cfg.is_preserved("amazon.com", "ref"));
178 assert!(!cfg.is_preserved("amazon.com", "nope"));
179 }
180
181 #[test]
182 fn always_functional_wins_over_dropped_list() {
183 let toml = r#"
187[canonical.domains]
188"example.com" = { preserve_params = ["ref"] }
189"#;
190 let cfg = parse(toml, Path::new("/dev/null")).unwrap();
191 assert!(cfg.is_preserved("any-host", "id"));
194 assert!(cfg.is_preserved("any-host", "page"));
195 assert!(cfg.is_preserved("any-host", "q"));
196 assert!(cfg.is_preserved("example.com", "ref"));
198 assert!(!cfg.is_preserved("other.com", "ref"));
200 }
201
202 #[test]
203 fn host_lowercased_on_parse() {
204 let toml = r#"
205[canonical.domains]
206"AMAZON.COM" = { preserve_params = ["tag"] }
207"#;
208 let cfg = parse(toml, Path::new("/dev/null")).unwrap();
209 assert!(cfg.domains.contains_key("amazon.com"));
210 assert!(!cfg.domains.contains_key("AMAZON.COM"));
211 }
212
213 #[test]
214 fn param_names_lowercased_on_parse() {
215 let toml = r#"
216[canonical.domains]
217"amazon.com" = { preserve_params = ["TAG", "Ref"] }
218"#;
219 let cfg = parse(toml, Path::new("/dev/null")).unwrap();
220 let rules = cfg.domains.get("amazon.com").expect("host present");
221 assert_eq!(
222 rules.preserve_params,
223 vec!["tag".to_string(), "ref".to_string()]
224 );
225 }
226
227 #[test]
228 fn unknown_top_level_section_rejected() {
229 let toml = "[cannonical.domains]\n\"x.com\" = { preserve_params = [\"a\"] }\n";
232 let err = parse(toml, Path::new("/dev/null")).unwrap_err();
233 match err {
234 CoreError::Storage(msg) => assert!(msg.contains("cannonical"), "got: {msg}"),
235 other => panic!("expected Storage, got: {other:?}"),
236 }
237 }
238
239 #[test]
240 fn ensure_default_is_idempotent() {
241 let parsed = parse(DEFAULT_CONFIG_BODY, Path::new("/dev/null")).unwrap();
244 assert!(parsed.is_preserved("youtube.com", "t"));
245 assert!(parsed.is_preserved("github.com", "q"));
246 assert!(parsed.is_preserved("amazon.com", "tag"));
247 let _ = DomainRules::default();
249 }
250}