Skip to main content

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 [MultiFormat][MaybeMultiFormatConfig] string to customise the output of this block. See below for available placeholders. | `[" $icon $available "]`
10//! `warning` | A value which will trigger warning block state | `20.0`
11//! `alert` | A value which will trigger critical block state | `10.0`
12//! `info_type` | Determines which information will affect the block state. Possible values are `"available"`, `"free"` and `"used"` | `"available"`
13//! `alert_unit` | The unit of `alert` and `warning` options. If not set, percents are used. Possible values are `"B"`, `"kB"`, `"KB"`, `"KiB"`, `"MB"`, `"MiB"`, `"GB"`, `"Gib"`, `"TB"` and `"TiB"` | `None`
14//! `backend` | The backend to use when querying disk usage. Possible values are `"vfs"` (like `du(1)`) and `"btrfs"` | `"vfs"`
15//!
16//! Placeholder  | Value                                                              | Type   | Unit
17//! -------------|--------------------------------------------------------------------|--------|-------
18//! `icon`       | A static icon                                                      | Icon   | -
19//! `path`       | The value of `path` option                                         | Text   | -
20//! `percentage` | Free or used percentage. Depends on `info_type`                    | Number | %
21//! `total`      | Total disk space                                                   | Number | Bytes
22//! `used`       | Used disk space                                                    | Number | Bytes
23//! `free`       | Free disk space                                                    | Number | Bytes
24//! `available`  | Available disk space (free disk space minus reserved system space) | Number | Bytes
25//!
26//! Action          | Description                               | Default button
27//! ----------------|-------------------------------------------|---------------
28//! `toggle_format` **DEPRECATED** | Toggles between `format` and `format_alt` | -
29//! `next_format`  | Switches to the next format in the list     | Left
30//! `prev_format`  | Switches to the previous format in the list | Right
31//!
32//! # Examples
33//!
34//! ```toml
35//! [[block]]
36//! block = "disk_space"
37//! info_type = "available"
38//! alert_unit = "GB"
39//! alert = 10.0
40//! warning = 15.0
41//! format = " $icon $available "
42//! format_alt = " $icon $available / $total "
43//! ```
44//!
45//! Update block on right click:
46//!
47//! ```toml
48//! [[block]]
49//! block = "disk_space"
50//! [[block.click]]
51//! button = "right"
52//! update = true
53//! ```
54//!
55//! Show the block only if less than 10GB is available:
56//!
57//! ```toml
58//! [[block]]
59//! block = "disk_space"
60//! format = " $free.eng(range:..10e9) |"
61//! ```
62//!
63//! # Icons Used
64//! - `disk_drive`
65
66// make_log_macro!(debug, "disk_space");
67
68use std::cell::OnceCell;
69
70use super::prelude::*;
71use crate::formatting::prefix::Prefix;
72use nix::sys::statvfs::statvfs;
73use tokio::process::Command;
74
75#[derive(Copy, Clone, Debug, Deserialize, SmartDefault)]
76#[serde(rename_all = "lowercase")]
77pub enum InfoType {
78    #[default]
79    Available,
80    Free,
81    Used,
82}
83
84#[derive(Copy, Clone, Debug, Deserialize, SmartDefault)]
85#[serde(rename_all = "lowercase")]
86pub enum Backend {
87    #[default]
88    Vfs,
89    Btrfs,
90}
91
92#[derive(Deserialize, Debug, SmartDefault)]
93#[serde(default)]
94pub struct Config {
95    #[default("/".into())]
96    pub path: ShellString,
97    pub backend: Backend,
98    pub info_type: InfoType,
99    #[serde(flatten)]
100    pub formats: MaybeMultiFormatConfig,
101    pub alert_unit: Option<String>,
102    #[default(20.into())]
103    pub interval: Seconds,
104    #[default(20.0)]
105    pub warning: f64,
106    #[default(10.0)]
107    pub alert: f64,
108}
109
110pub async fn run(config: &Config, api: &CommonApi) -> Result<()> {
111    let mut actions = api.get_actions()?;
112    api.set_default_actions(&[
113        (MouseButton::Left, None, "next_format"),
114        (MouseButton::Right, None, "prev_format"),
115    ])?;
116
117    let mut formats = config.formats.with_default(" $icon $available ")?;
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("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(formats.get_format());
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                    "next_format" | "toggle_format" => {
202                        formats.next_format();
203                        break;
204                    }
205                    "prev_format" => {
206                        formats.prev_format();
207                        break;
208                    }
209                    _ => (),
210                }
211            }
212        }
213    }
214}
215
216fn get_vfs<P>(path: &P) -> Result<(u64, u64, u64, u64)>
217where
218    P: ?Sized + nix::NixPath,
219{
220    let statvfs = statvfs(path).error("failed to retrieve statvfs")?;
221
222    // Casting to be compatible with 32-bit systems
223    #[allow(clippy::unnecessary_cast)]
224    {
225        let total = (statvfs.blocks() as u64) * (statvfs.fragment_size() as u64);
226        let used = ((statvfs.blocks() as u64) - (statvfs.blocks_free() as u64))
227            * (statvfs.fragment_size() as u64);
228        let available = (statvfs.blocks_available() as u64) * (statvfs.block_size() as u64);
229        let free = (statvfs.blocks_free() as u64) * (statvfs.block_size() as u64);
230
231        Ok((total, used, available, free))
232    }
233}
234
235async fn get_btrfs(path: &str) -> Result<(u64, u64, u64, u64)> {
236    const OUTPUT_CHANGED: &str = "Btrfs filesystem usage output format changed";
237
238    fn remove_estimate_min(estimate_str: &str) -> Result<&str> {
239        estimate_str
240            .trim_matches('\t')
241            .split_once("\t")
242            .ok_or(Error::new(OUTPUT_CHANGED))
243            .map(|v| v.0)
244    }
245
246    macro_rules! get {
247        ($source:expr, $name:expr, $variable:ident) => {
248            get!(@pre_op (|a| {Ok::<_, Error>(a)}), $source, $name, $variable)
249        };
250        (@pre_op $function:expr, $source:expr, $name:expr, $variable:ident) => {
251            if $source.starts_with(concat!($name, ":")) {
252                let (found_name, variable_str) =
253                    $source.split_once(":").ok_or(Error::new(OUTPUT_CHANGED))?;
254
255                let variable_str = $function(variable_str)?;
256
257                debug_assert_eq!(found_name, $name);
258                $variable
259                    .set(variable_str.trim().parse().error(OUTPUT_CHANGED)?)
260                    .map_err(|_| Error::new(OUTPUT_CHANGED))?;
261            }
262        };
263    }
264
265    let filesystem_usage = Command::new("btrfs")
266        .args(["filesystem", "usage", "--raw", path])
267        .output()
268        .await
269        .error("Failed to collect btrfs filesystem usage info")?
270        .stdout;
271
272    {
273        let final_total = OnceCell::new();
274        let final_used = OnceCell::new();
275        let final_free = OnceCell::new();
276
277        let mut lines = filesystem_usage.lines();
278        while let Some(line) = lines
279            .next_line()
280            .await
281            .error("Failed to read output of btrfs filesystem usage")?
282        {
283            let line = line.trim();
284
285            // See btrfs-filesystem(8) for an explanation for the rows.
286            get!(line, "Device size", final_total);
287            get!(line, "Used", final_used);
288            get!(@pre_op remove_estimate_min, line, "Free (estimated)", final_free);
289        }
290
291        Ok((
292            *final_total.get().ok_or(Error::new(OUTPUT_CHANGED))?,
293            *final_used.get().ok_or(Error::new(OUTPUT_CHANGED))?,
294            // HACK(@bpeetz): We also return the free disk space as the available one, because btrfs
295            // does not tell us which disk space is reserved for the fs. <2025-05-18>
296            *final_free.get().ok_or(Error::new(OUTPUT_CHANGED))?,
297            *final_free.get().ok_or(Error::new(OUTPUT_CHANGED))?,
298        ))
299    }
300}