i3status_rs/blocks/
disk_space.rs

1//! Disk usage statistics
2//!
3//! # Configuration
4//!
5//! Key | Values | Default
6//! ----|--------|--------
7//! `path` | Path to collect information from. Supports path expansions e.g. `~`. | `"/"`
8//! `interval` | Update time in seconds | `20`
9//! `format` | A string to customise the output of this block. See below for available placeholders. | `" $icon $available "`
10//! `format_alt` | If set, block will switch between `format` and `format_alt` on every click | `None`
11//! `warning` | A value which will trigger warning block state | `20.0`
12//! `alert` | A value which will trigger critical block state | `10.0`
13//! `info_type` | Determines which information will affect the block state. Possible values are `"available"`, `"free"` and `"used"` | `"available"`
14//! `alert_unit` | The unit of `alert` and `warning` options. If not set, percents are used. Possible values are `"B"`, `"KB"`, `"KiB"`, `"MB"`, `"MiB"`, `"GB"`, `"Gib"`, `"TB"` and `"TiB"` | `None`
15//! `backend` | The backend to use when querying disk usage. Possible values are `"vfs"` (like `du(1)`) and `"btrfs"` | `"vfs"`
16//!
17//! Placeholder  | Value                                                              | Type   | Unit
18//! -------------|--------------------------------------------------------------------|--------|-------
19//! `icon`       | A static icon                                                      | Icon   | -
20//! `path`       | The value of `path` option                                         | Text   | -
21//! `percentage` | Free or used percentage. Depends on `info_type`                    | Number | %
22//! `total`      | Total disk space                                                   | Number | Bytes
23//! `used`       | Used disk space                                                    | Number | Bytes
24//! `free`       | Free disk space                                                    | Number | Bytes
25//! `available`  | Available disk space (free disk space minus reserved system space) | Number | Bytes
26//!
27//! Action          | Description                               | Default button
28//! ----------------|-------------------------------------------|---------------
29//! `toggle_format` | Toggles between `format` and `format_alt` | Left
30//!
31//! # Examples
32//!
33//! ```toml
34//! [[block]]
35//! block = "disk_space"
36//! info_type = "available"
37//! alert_unit = "GB"
38//! alert = 10.0
39//! warning = 15.0
40//! format = " $icon $available "
41//! format_alt = " $icon $available / $total "
42//! ```
43//!
44//! Update block on right click:
45//!
46//! ```toml
47//! [[block]]
48//! block = "disk_space"
49//! [[block.click]]
50//! button = "right"
51//! update = true
52//! ```
53//!
54//! Show the block only if less than 10GB is available:
55//!
56//! ```toml
57//! [[block]]
58//! block = "disk_space"
59//! format = " $free.eng(range:..10e9) |"
60//! ```
61//!
62//! # Icons Used
63//! - `disk_drive`
64
65// make_log_macro!(debug, "disk_space");
66
67use std::cell::OnceCell;
68
69use super::prelude::*;
70use crate::formatting::prefix::Prefix;
71use nix::sys::statvfs::statvfs;
72use tokio::process::Command;
73
74#[derive(Copy, Clone, Debug, Deserialize, SmartDefault)]
75#[serde(rename_all = "lowercase")]
76pub enum InfoType {
77    #[default]
78    Available,
79    Free,
80    Used,
81}
82
83#[derive(Copy, Clone, Debug, Deserialize, SmartDefault)]
84#[serde(rename_all = "lowercase")]
85pub enum Backend {
86    #[default]
87    Vfs,
88    Btrfs,
89}
90
91#[derive(Deserialize, Debug, SmartDefault)]
92#[serde(deny_unknown_fields, default)]
93pub struct Config {
94    #[default("/".into())]
95    pub path: ShellString,
96    pub backend: Backend,
97    pub info_type: InfoType,
98    pub format: FormatConfig,
99    pub format_alt: Option<FormatConfig>,
100    pub alert_unit: Option<String>,
101    #[default(20.into())]
102    pub interval: Seconds,
103    #[default(20.0)]
104    pub warning: f64,
105    #[default(10.0)]
106    pub alert: f64,
107}
108
109pub async fn run(config: &Config, api: &CommonApi) -> Result<()> {
110    let mut actions = api.get_actions()?;
111    api.set_default_actions(&[(MouseButton::Left, None, "toggle_format")])?;
112
113    let mut format = config.format.with_default(" $icon $available ")?;
114    let mut format_alt = match &config.format_alt {
115        Some(f) => Some(f.with_default("")?),
116        None => None,
117    };
118
119    let unit = match config.alert_unit.as_deref() {
120        // Decimal
121        Some("TB") => Some(Prefix::Tera),
122        Some("GB") => Some(Prefix::Giga),
123        Some("MB") => Some(Prefix::Mega),
124        Some("KB") => Some(Prefix::Kilo),
125        // Binary
126        Some("TiB") => Some(Prefix::Tebi),
127        Some("GiB") => Some(Prefix::Gibi),
128        Some("MiB") => Some(Prefix::Mebi),
129        Some("KiB") => Some(Prefix::Kibi),
130        // Byte
131        Some("B") => Some(Prefix::One),
132        // Unknown
133        Some(x) => return Err(Error::new(format!("Unknown unit: '{x}'"))),
134        None => None,
135    };
136
137    let path = config.path.expand()?;
138
139    let mut timer = config.interval.timer();
140
141    loop {
142        let mut widget = Widget::new().with_format(format.clone());
143
144        let (total, used, available, free) = match config.backend {
145            Backend::Vfs => get_vfs(&*path)?,
146            Backend::Btrfs => get_btrfs(&path).await?,
147        };
148
149        let result = match config.info_type {
150            InfoType::Available => available,
151            InfoType::Free => free,
152            InfoType::Used => used,
153        } as f64;
154
155        let percentage = result / (total as f64) * 100.;
156        widget.set_values(map! {
157            "icon" => Value::icon("disk_drive"),
158            "path" => Value::text(path.to_string()),
159            "percentage" => Value::percents(percentage),
160            "total" => Value::bytes(total as f64),
161            "used" => Value::bytes(used as f64),
162            "available" => Value::bytes(available as f64),
163            "free" => Value::bytes(free as f64),
164        });
165
166        // Send percentage to alert check if we don't want absolute alerts
167        let alert_val_in_config_units = match unit {
168            Some(p) => p.apply(result),
169            None => percentage,
170        };
171
172        // Compute state
173        widget.state = match config.info_type {
174            InfoType::Used => {
175                if alert_val_in_config_units >= config.alert {
176                    State::Critical
177                } else if alert_val_in_config_units >= config.warning {
178                    State::Warning
179                } else {
180                    State::Idle
181                }
182            }
183            InfoType::Free | InfoType::Available => {
184                if alert_val_in_config_units <= config.alert {
185                    State::Critical
186                } else if alert_val_in_config_units <= config.warning {
187                    State::Warning
188                } else {
189                    State::Idle
190                }
191            }
192        };
193
194        api.set_widget(widget)?;
195
196        loop {
197            select! {
198                _ = timer.tick() => break,
199                _ = api.wait_for_update_request() => break,
200                Some(action) = actions.recv() => match action.as_ref() {
201                    "toggle_format" => {
202                        if let Some(format_alt) = &mut format_alt {
203                            std::mem::swap(format_alt, &mut format);
204                            break;
205                        }
206                    }
207                    _ => (),
208                }
209            }
210        }
211    }
212}
213
214fn get_vfs<P>(path: &P) -> Result<(u64, u64, u64, u64)>
215where
216    P: ?Sized + nix::NixPath,
217{
218    let statvfs = statvfs(path).error("failed to retrieve statvfs")?;
219
220    // Casting to be compatible with 32-bit systems
221    #[allow(clippy::unnecessary_cast)]
222    {
223        let total = (statvfs.blocks() as u64) * (statvfs.fragment_size() as u64);
224        let used = ((statvfs.blocks() as u64) - (statvfs.blocks_free() as u64))
225            * (statvfs.fragment_size() as u64);
226        let available = (statvfs.blocks_available() as u64) * (statvfs.block_size() as u64);
227        let free = (statvfs.blocks_free() as u64) * (statvfs.block_size() as u64);
228
229        Ok((total, used, available, free))
230    }
231}
232
233async fn get_btrfs(path: &str) -> Result<(u64, u64, u64, u64)> {
234    const OUTPUT_CHANGED: &str = "Btrfs filesystem usage output format changed";
235
236    fn remove_estimate_min(estimate_str: &str) -> Result<&str> {
237        estimate_str
238            .trim_matches('\t')
239            .split_once("\t")
240            .ok_or(Error::new(OUTPUT_CHANGED))
241            .map(|v| v.0)
242    }
243
244    macro_rules! get {
245        ($source:expr, $name:expr, $variable:ident) => {
246            get!(@pre_op (|a| {Ok::<_, Error>(a)}), $source, $name, $variable)
247        };
248        (@pre_op $function:expr, $source:expr, $name:expr, $variable:ident) => {
249            if $source.starts_with(concat!($name, ":")) {
250                let (found_name, variable_str) =
251                    $source.split_once(":").ok_or(Error::new(OUTPUT_CHANGED))?;
252
253                let variable_str = $function(variable_str)?;
254
255                debug_assert_eq!(found_name, $name);
256                $variable
257                    .set(variable_str.trim().parse().error(OUTPUT_CHANGED)?)
258                    .map_err(|_| Error::new(OUTPUT_CHANGED))?;
259            }
260        };
261    }
262
263    let filesystem_usage = Command::new("btrfs")
264        .args(["filesystem", "usage", "--raw", path])
265        .output()
266        .await
267        .error("Failed to collect btrfs filesystem usage info")?
268        .stdout;
269
270    {
271        let final_total = OnceCell::new();
272        let final_used = OnceCell::new();
273        let final_free = OnceCell::new();
274
275        let mut lines = filesystem_usage.lines();
276        while let Some(line) = lines
277            .next_line()
278            .await
279            .error("Failed to read output of btrfs filesystem usage")?
280        {
281            let line = line.trim();
282
283            // See btrfs-filesystem(8) for an explanation for the rows.
284            get!(line, "Device size", final_total);
285            get!(line, "Used", final_used);
286            get!(@pre_op remove_estimate_min, line, "Free (estimated)", final_free);
287        }
288
289        Ok((
290            *final_total.get().ok_or(Error::new(OUTPUT_CHANGED))?,
291            *final_used.get().ok_or(Error::new(OUTPUT_CHANGED))?,
292            // HACK(@bpeetz): We also return the free disk space as the available one, because btrfs
293            // does not tell us which disk space is reserved for the fs. <2025-05-18>
294            *final_free.get().ok_or(Error::new(OUTPUT_CHANGED))?,
295            *final_free.get().ok_or(Error::new(OUTPUT_CHANGED))?,
296        ))
297    }
298}