i3status_rs/
util.rs

1use std::path::{Path, PathBuf};
2
3use dirs::{config_dir, data_dir};
4use serde::de::DeserializeOwned;
5use tokio::io::AsyncReadExt as _;
6use tokio::process::Command;
7
8use crate::errors::*;
9
10/// Tries to find a file in standard locations:
11/// - Fist try to find a file by full path (only if path is absolute)
12/// - Then try XDG_CONFIG_HOME (e.g. `~/.config`)
13/// - Then try XDG_DATA_HOME (e.g. `~/.local/share/`)
14/// - Then try `/usr/share/`
15///
16/// Automatically append an extension if not presented.
17pub fn find_file(
18    file: &str,
19    subdir: Option<&str>,
20    extension: Option<&str>,
21) -> Result<Option<PathBuf>> {
22    let file = Path::new(file);
23
24    if file.is_absolute() && file.try_exists().error("Unable to stat file")? {
25        return Ok(Some(file.to_path_buf()));
26    }
27
28    // Try XDG_CONFIG_HOME (e.g. `~/.config`)
29    if let Some(mut xdg_config) = config_dir() {
30        xdg_config.push("i3status-rust");
31        if let Some(subdir) = subdir {
32            xdg_config.push(subdir);
33        }
34        xdg_config.push(file);
35        if let Some(file) = exists_with_opt_extension(&xdg_config, extension)? {
36            return Ok(Some(file));
37        }
38    }
39
40    // Try XDG_DATA_HOME (e.g. `~/.local/share/`)
41    if let Some(mut xdg_data) = data_dir() {
42        xdg_data.push("i3status-rust");
43        if let Some(subdir) = subdir {
44            xdg_data.push(subdir);
45        }
46        xdg_data.push(file);
47        if let Some(file) = exists_with_opt_extension(&xdg_data, extension)? {
48            return Ok(Some(file));
49        }
50    }
51
52    // Try `/usr/share/`
53    let mut usr_share_path = PathBuf::from("/usr/share/i3status-rust");
54    if let Some(subdir) = subdir {
55        usr_share_path.push(subdir);
56    }
57    usr_share_path.push(file);
58    if let Some(file) = exists_with_opt_extension(&usr_share_path, extension)? {
59        return Ok(Some(file));
60    }
61
62    Ok(None)
63}
64
65fn exists_with_opt_extension(file: &Path, extension: Option<&str>) -> Result<Option<PathBuf>> {
66    if file.try_exists().error("Unable to stat file")? {
67        return Ok(Some(file.into()));
68    }
69    // If file has no extension, test with given extension
70    if let (None, Some(extension)) = (file.extension(), extension) {
71        let file = file.with_extension(extension);
72        // Check again with extension added
73        if file.try_exists().error("Unable to stat file")? {
74            return Ok(Some(file));
75        }
76    }
77    Ok(None)
78}
79
80pub async fn new_dbus_connection() -> Result<zbus::Connection> {
81    zbus::Connection::session()
82        .await
83        .error("Failed to open DBus session connection")
84}
85
86pub async fn new_system_dbus_connection() -> Result<zbus::Connection> {
87    zbus::Connection::system()
88        .await
89        .error("Failed to open DBus system connection")
90}
91
92pub fn deserialize_toml_file<T, P>(path: P) -> Result<T>
93where
94    T: DeserializeOwned,
95    P: AsRef<Path>,
96{
97    let path = path.as_ref();
98
99    let contents = std::fs::read_to_string(path)
100        .or_error(|| format!("Failed to read file: {}", path.display()))?;
101
102    deserialize_toml_file_string(contents, path)
103}
104
105pub async fn async_deserialize_toml_file<T, P>(path: P) -> Result<T>
106where
107    T: DeserializeOwned,
108    P: AsRef<Path>,
109{
110    let path = path.as_ref();
111
112    let contents = read_file(path)
113        .await
114        .or_error(|| format!("Failed to read file: {}", path.display()))?;
115
116    deserialize_toml_file_string(contents, path)
117}
118
119fn deserialize_toml_file_string<T>(contents: String, path: &Path) -> Result<T>
120where
121    T: DeserializeOwned,
122{
123    toml::from_str(&contents).map_err(|err| {
124        let location_msg = err
125            .span()
126            .map(|span| {
127                if span == (0..0) {
128                    String::new()
129                } else {
130                    let line = 1 + contents.as_bytes()[..(span.start)]
131                        .iter()
132                        .filter(|b| **b == b'\n')
133                        .count();
134                    format!(" at line {line}")
135                }
136            })
137            .unwrap_or_default();
138        Error::new(format!(
139            "Failed to deserialize TOML file {}{}: {}",
140            path.display(),
141            location_msg,
142            err.message()
143        ))
144    })
145}
146
147pub async fn read_file(path: impl AsRef<Path>) -> std::io::Result<String> {
148    let mut file = tokio::fs::File::open(path).await?;
149    let mut content = String::new();
150    file.read_to_string(&mut content).await?;
151    Ok(content.trim_end().to_string())
152}
153
154pub async fn has_command(command: &str) -> Result<bool> {
155    Command::new("sh")
156        .args([
157            "-c",
158            format!("command -v {command} >/dev/null 2>&1").as_ref(),
159        ])
160        .status()
161        .await
162        .or_error(|| format!("Failed to check {command} presence"))
163        .map(|status| status.success())
164}
165
166/// # Example
167///
168/// ```ignore
169/// let opt = Some(1);
170/// let m: HashMap<&'static str, String> = map! {
171///     "key" => "value",
172///     [if true] "hello" => "world",
173///     [if let Some(x) = opt] "opt" => x.to_string(),
174/// };
175/// map! { @extend m
176///     "new key" => "new value",
177///     "one" => "more",
178/// }
179/// ```
180#[macro_export]
181macro_rules! map {
182    (@extend $map:ident $( $([$($cond_tokens:tt)*])? $key:literal => $value:expr ),* $(,)?) => {{
183        $(
184        map!(@insert $map, $key, $value $(,$($cond_tokens)*)?);
185        )*
186    }};
187    (@extend $map:ident $( $key:expr => $value:expr ),* $(,)?) => {{
188        $(
189        map!(@insert $map, $key, $value);
190        )*
191    }};
192    (@insert $map:ident, $key:expr, $value:expr) => {{
193        $map.insert($key.into(), $value.into());
194    }};
195    (@insert $map:ident, $key:expr, $value:expr, if $cond:expr) => {{
196        if $cond {
197        $map.insert($key.into(), $value.into());
198        }
199    }};
200    (@insert $map:ident, $key:expr, $value:expr, if let $pat:pat = $match_on:expr) => {{
201        if let $pat = $match_on {
202        $map.insert($key.into(), $value.into());
203        }
204    }};
205    ($($tt:tt)*) => {{
206        #[allow(unused_mut)]
207        let mut m = ::std::collections::HashMap::new();
208        map!(@extend m $($tt)*);
209        m
210    }};
211}
212
213pub use map;
214
215macro_rules! regex {
216    ($re:literal $(,)?) => {{
217        static RE: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
218        RE.get_or_init(|| regex::Regex::new($re).unwrap())
219    }};
220}
221
222macro_rules! make_log_macro {
223    (@wdoll $macro_name:ident, $block_name:literal, ($dol:tt)) => {
224        #[allow(dead_code)]
225        macro_rules! $macro_name {
226            ($dol($args:tt)+) => {
227                ::log::$macro_name!(target: $block_name, $dol($args)+);
228            };
229        }
230    };
231    ($macro_name:ident, $block_name:literal) => {
232        make_log_macro!(@wdoll $macro_name, $block_name, ($));
233    };
234}
235
236pub fn format_bar_graph(content: &[f64]) -> String {
237    // (x * one eighth block) https://en.wikipedia.org/wiki/Block_Elements
238    static BARS: [char; 8] = [
239        '\u{2581}', '\u{2582}', '\u{2583}', '\u{2584}', '\u{2585}', '\u{2586}', '\u{2587}',
240        '\u{2588}',
241    ];
242
243    // Find min and max
244    let mut min = f64::INFINITY;
245    let mut max = f64::NEG_INFINITY;
246    for &v in content {
247        min = min.min(v);
248        max = max.max(v);
249    }
250
251    let range = max - min;
252    content
253        .iter()
254        .map(|x| BARS[((x - min) / range * 7.).clamp(0., 7.) as usize])
255        .collect()
256}
257
258/// Convert 2 letter country code to Unicode
259pub fn country_flag_from_iso_code(country_code: &str) -> String {
260    let [mut b1, mut b2]: [u8; 2] = country_code.as_bytes().try_into().unwrap_or([0, 0]);
261
262    if !b1.is_ascii_uppercase() || !b2.is_ascii_uppercase() {
263        return country_code.into();
264    }
265
266    // Each char is encoded as 1F1E6 to 1F1FF for A-Z
267    b1 += 0xa5;
268    b2 += 0xa5;
269    // The last byte will always start with 101 (0xa0) and then the 5 least
270    // significant bits from the previous result
271    b1 = 0xa0 | (b1 & 0x1f);
272    b2 = 0xa0 | (b2 & 0x1f);
273    // Get the flag string from the UTF-8 representation of our Unicode characters.
274    String::from_utf8(vec![0xf0, 0x9f, 0x87, b1, 0xf0, 0x9f, 0x87, b2]).unwrap()
275}
276
277/// A shortcut for `Default::default()`
278/// See <https://github.com/rust-lang/rust/issues/73014>
279#[inline]
280pub fn default<T: Default>() -> T {
281    Default::default()
282}
283
284pub trait StreamExtDebounced: futures::StreamExt {
285    fn next_debounced(&mut self) -> impl Future<Output = Option<Self::Item>>;
286}
287
288impl<T: futures::StreamExt + Unpin> StreamExtDebounced for T {
289    async fn next_debounced(&mut self) -> Option<Self::Item> {
290        let mut result = self.next().await?;
291        let mut noop_ctx = std::task::Context::from_waker(std::task::Waker::noop());
292        loop {
293            match self.poll_next_unpin(&mut noop_ctx) {
294                std::task::Poll::Ready(Some(x)) => result = x,
295                std::task::Poll::Ready(None) | std::task::Poll::Pending => return Some(result),
296            }
297        }
298    }
299}
300
301#[cfg(test)]
302mod tests {
303    use super::*;
304
305    #[tokio::test]
306    async fn test_has_command_ok() {
307        // we assume sh is always available
308        assert!(has_command("sh").await.unwrap());
309    }
310
311    #[tokio::test]
312    async fn test_has_command_err() {
313        // we assume thequickbrownfoxjumpsoverthelazydog command does not exist
314        assert!(
315            !has_command("thequickbrownfoxjumpsoverthelazydog")
316                .await
317                .unwrap()
318        );
319    }
320
321    #[test]
322    fn test_flags() {
323        assert!(country_flag_from_iso_code("ES") == "πŸ‡ͺπŸ‡Έ");
324        assert!(country_flag_from_iso_code("US") == "πŸ‡ΊπŸ‡Έ");
325        assert!(country_flag_from_iso_code("USA") == "USA");
326    }
327}