Skip to main content

datui_lib/
user_agent.rs

1//! The User-Agent header on every request datui makes: ureq's, and the object
2//! stores' (ours and the ones Polars builds for a scan).
3//!
4//! It names datui, its version and where to read about it, so a publisher whose
5//! logs show datui can find out what it is. Nothing about the user or the machine.
6//! `[http] user_agent` replaces it.
7
8use std::sync::RwLock;
9
10/// What datui sends unless `[http] user_agent` says otherwise.
11pub const DEFAULT: &str = concat!(
12    "datui/",
13    env!("CARGO_PKG_VERSION"),
14    " (+https://github.com/derekwisong/datui)"
15);
16
17/// The run's `[http] user_agent`, set once its configuration is read. A process
18/// global because the clients are built deep in workers that hold no config.
19static CONFIGURED: RwLock<Option<String>> = RwLock::new(None);
20
21/// The header for a `[http] user_agent` setting: the setting, or the default when it
22/// is blank.
23pub fn resolve(setting: &str) -> String {
24    let setting = setting.trim();
25    if setting.is_empty() {
26        DEFAULT.to_string()
27    } else {
28        setting.to_string()
29    }
30}
31
32/// Whether `value` can be sent as a header: printable ASCII, so neither ureq nor
33/// object_store refuses it at request time.
34pub fn is_valid(value: &str) -> bool {
35    value.chars().all(|c| (' '..='~').contains(&c))
36}
37
38/// Use `setting` (`[http] user_agent`) from now on in this process.
39pub fn configure(setting: &str) {
40    let value = setting.trim();
41    *CONFIGURED.write().unwrap_or_else(|e| e.into_inner()) =
42        (!value.is_empty()).then(|| value.to_string());
43}
44
45/// The header to send now.
46pub fn get() -> String {
47    match CONFIGURED
48        .read()
49        .unwrap_or_else(|e| e.into_inner())
50        .as_deref()
51    {
52        Some(value) => value.to_string(),
53        None => DEFAULT.to_string(),
54    }
55}
56
57/// A ureq agent configuration with the header set; each client adds its own limits.
58#[cfg(any(feature = "http", feature = "cloud"))]
59pub fn ureq_config() -> ureq::config::ConfigBuilder<ureq::typestate::AgentScope> {
60    ureq::Agent::config_builder().user_agent(get())
61}
62
63/// The object_store client key that carries the header, for a builder's `with_config`
64/// or Polars' `CloudOptions`.
65#[cfg(feature = "cloud")]
66pub const CLIENT_KEY: object_store::ClientConfigKey = object_store::ClientConfigKey::UserAgent;
67
68#[cfg(test)]
69mod tests {
70    use super::*;
71
72    #[test]
73    fn the_default_names_datui_its_version_and_home_and_nothing_else() {
74        assert_eq!(
75            DEFAULT,
76            format!(
77                "datui/{} (+https://github.com/derekwisong/datui)",
78                env!("CARGO_PKG_VERSION")
79            )
80        );
81        assert!(is_valid(DEFAULT));
82    }
83
84    #[test]
85    fn a_blank_setting_is_the_default_and_anything_else_replaces_it() {
86        assert_eq!(resolve(""), DEFAULT);
87        assert_eq!(resolve("   "), DEFAULT);
88        assert_eq!(resolve(" research-crawler/2 "), "research-crawler/2");
89    }
90
91    #[test]
92    fn control_characters_are_not_a_header() {
93        assert!(is_valid("my-tool/1.0 (+https://example.com)"));
94        assert!(!is_valid("a\r\nX-Injected: 1"));
95        assert!(!is_valid("café"));
96    }
97}