Skip to main content

linkmarks_core/
config.rs

1//! TOML config loader for LinkMarks.
2//!
3//! Reads `${XDG_CONFIG_HOME:-~/.config}/linkmarks/config.toml` and parses
4//! it into a [`CanonicalConfig`]. The loader is **not** hot-reload:
5//! a single read per process is the contract. Hot-reload would require
6//! `notify` + state synchronization and is out of scope today.
7//!
8//! ## Fallback policy
9//!
10//! 1. Missing file → [`CanonicalConfig::default_rules()`] (the empty
11//!    `CanonicalConfig`). No error.
12//! 2. Empty file (zero bytes) → defaults, no error.
13//! 3. Parse error → [`CoreError::Storage`] with the path attached, so
14//!    `init` / `list` can surface a clear diagnostic.
15//!
16//! The config shape is intentionally tiny — a single
17//! `[canonical.domains.<host>]` table — so a hand-written TOML in the
18//! user's editor stays readable.
19
20use crate::canonical_config::CanonicalConfig;
21use crate::errors::CoreError;
22use crate::paths;
23use serde::Deserialize;
24use std::collections::HashMap;
25use std::path::Path;
26
27/// On-disk representation of `config.toml`. Decoupled from
28/// `CanonicalConfig` so future top-level sections (e.g. `[net]`,
29/// `[storage]`) can be added without disturbing the canonical-rules API.
30#[derive(Debug, Default, Deserialize)]
31#[serde(deny_unknown_fields)]
32struct OnDiskConfig {
33    /// Canonicalization rules. May be absent in which case defaults apply.
34    #[serde(default)]
35    canonical: CanonicalSection,
36}
37
38#[derive(Debug, Default, Deserialize)]
39struct CanonicalSection {
40    /// Per-domain preservation overrides.
41    #[serde(default)]
42    domains: HashMap<String, DomainSection>,
43}
44
45#[derive(Debug, Default, Deserialize)]
46struct DomainSection {
47    /// Query parameter names that must be preserved (lowercase, exact).
48    #[serde(default)]
49    preserve_params: Vec<String>,
50}
51
52/// Public entry point: load the default config file.
53///
54/// Missing or empty file → defaults. Invalid file → error.
55pub fn load() -> Result<CanonicalConfig, CoreError> {
56    let path = paths::linkmarks_config_path();
57    load_from(&path)
58}
59
60/// Load from an explicit path. Used by tests.
61pub 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        // Lowercase the host for stable lookup.
80        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
95/// Write the default config file at the standard XDG path if missing.
96///
97/// Returns `true` when a fresh file was written, `false` when the file
98/// already existed.
99pub 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
112/// Reference TOML body used by `ensure_default`. Kept in sync with the
113/// shipped `config.toml.example` at the workspace root.
114pub 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        // `ref` is normally a tracking param; the default blocklist
184        // drops it. But ALWAYS_FUNCTIONAL (via canonical_config) must
185        // override when the user explicitly opts in.
186        let toml = r#"
187[canonical.domains]
188"example.com" = { preserve_params = ["ref"] }
189"#;
190        let cfg = parse(toml, Path::new("/dev/null")).unwrap();
191        // `id` is ALWAYS_FUNCTIONAL and must survive even without any
192        // explicit opt-in.
193        assert!(cfg.is_preserved("any-host", "id"));
194        assert!(cfg.is_preserved("any-host", "page"));
195        assert!(cfg.is_preserved("any-host", "q"));
196        // `ref` for the specific host is preserved.
197        assert!(cfg.is_preserved("example.com", "ref"));
198        // But `ref` for an unrelated host is dropped.
199        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        // Configs intentionally deny unknown fields to surface typos
230        // like `[cannonical]` early.
231        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        // We can't use the default XDG path in tests (would mutate the
242        // real config), so this just exercises the parse path.
243        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        // baseline DomainRules behavior still applies.
248        let _ = DomainRules::default();
249    }
250}