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` (`$icon`)
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(crate) fn prepare(config: &Config) -> Result<Arc<BlockPlan>> {
111    // Every output renders the same value set, built unconditionally on
112    // every update.
113    let declare = |output: OutputPlan| output.icon("icon", IconChoices::one(icons::DISK_DRIVE));
114    let formats = config.formats.with_default(" $icon $available ")?;
115    BlockPlan::new(format_outputs(formats, declare))
116}
117
118pub(crate) async fn run(config: &Config, api: &CommonApi, plan: &Arc<BlockPlan>) -> Result<()> {
119    let mut actions = api.get_actions()?;
120    api.set_default_actions(&[
121        (MouseButton::Left, None, "next_format"),
122        (MouseButton::Right, None, "prev_format"),
123    ])?;
124
125    let mut formats = FormatRotation::new(plan)?;
126
127    let unit = match config.alert_unit.as_deref() {
128        // Decimal
129        Some("TB") => Some(Prefix::Tera),
130        Some("GB") => Some(Prefix::Giga),
131        Some("MB") => Some(Prefix::Mega),
132        Some("KB") | Some("kB") => Some(Prefix::Kilo),
133        // Binary
134        Some("TiB") => Some(Prefix::Tebi),
135        Some("GiB") => Some(Prefix::Gibi),
136        Some("MiB") => Some(Prefix::Mebi),
137        Some("KiB") => Some(Prefix::Kibi),
138        // Byte
139        Some("B") => Some(Prefix::One),
140        // Unknown
141        Some(x) => return Err(Error::new(format!("Unknown unit: '{x}'"))),
142        None => None,
143    };
144
145    let path = config.path.expand()?;
146
147    let mut timer = config.interval.timer();
148
149    loop {
150        let output = formats.current();
151        let mut widget = output.new_widget();
152
153        let (total, used, available, free) = match config.backend {
154            Backend::Vfs => get_vfs(&*path)?,
155            Backend::Btrfs => get_btrfs(&path).await?,
156        };
157
158        let result = match config.info_type {
159            InfoType::Available => available,
160            InfoType::Free => free,
161            InfoType::Used => used,
162        } as f64;
163
164        let percentage = result / (total as f64) * 100.;
165        widget.set_values(map! {
166            "icon" => Value::icon(icons::DISK_DRIVE),
167            "path" => Value::text(path.to_string()),
168            "percentage" => Value::percents(percentage),
169            "total" => Value::bytes(total as f64),
170            "used" => Value::bytes(used as f64),
171            "available" => Value::bytes(available as f64),
172            "free" => Value::bytes(free as f64),
173        });
174
175        // Send percentage to alert check if we don't want absolute alerts
176        let alert_val_in_config_units = match unit {
177            Some(p) => p.apply(result),
178            None => percentage,
179        };
180
181        // Compute state
182        widget.state = match config.info_type {
183            InfoType::Used => {
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            InfoType::Free | InfoType::Available => {
193                if alert_val_in_config_units <= config.alert {
194                    State::Critical
195                } else if alert_val_in_config_units <= config.warning {
196                    State::Warning
197                } else {
198                    State::Idle
199                }
200            }
201        };
202
203        api.set_widget(widget)?;
204
205        loop {
206            select! {
207                _ = timer.tick() => break,
208                _ = api.wait_for_update_request() => break,
209                Some(action) = actions.recv() => match action.as_ref() {
210                    "next_format" | "toggle_format" => {
211                        formats.next();
212                        break;
213                    }
214                    "prev_format" => {
215                        formats.prev();
216                        break;
217                    }
218                    _ => (),
219                }
220            }
221        }
222    }
223}
224
225fn get_vfs<P>(path: &P) -> Result<(u64, u64, u64, u64)>
226where
227    P: ?Sized + nix::NixPath,
228{
229    let statvfs = statvfs(path).error("failed to retrieve statvfs")?;
230
231    // Casting to be compatible with 32-bit systems
232    #[allow(clippy::unnecessary_cast)]
233    {
234        let total = (statvfs.blocks() as u64) * (statvfs.fragment_size() as u64);
235        let used = ((statvfs.blocks() as u64) - (statvfs.blocks_free() as u64))
236            * (statvfs.fragment_size() as u64);
237        let available = (statvfs.blocks_available() as u64) * (statvfs.block_size() as u64);
238        let free = (statvfs.blocks_free() as u64) * (statvfs.block_size() as u64);
239
240        Ok((total, used, available, free))
241    }
242}
243
244async fn get_btrfs(path: &str) -> Result<(u64, u64, u64, u64)> {
245    const OUTPUT_CHANGED: &str = "Btrfs filesystem usage output format changed";
246
247    fn remove_estimate_min(estimate_str: &str) -> Result<&str> {
248        estimate_str
249            .trim_matches('\t')
250            .split_once("\t")
251            .ok_or(Error::new(OUTPUT_CHANGED))
252            .map(|v| v.0)
253    }
254
255    macro_rules! get {
256        ($source:expr, $name:expr, $variable:ident) => {
257            get!(@pre_op (|a| {Ok::<_, Error>(a)}), $source, $name, $variable)
258        };
259        (@pre_op $function:expr, $source:expr, $name:expr, $variable:ident) => {
260            if $source.starts_with(concat!($name, ":")) {
261                let (found_name, variable_str) =
262                    $source.split_once(":").ok_or(Error::new(OUTPUT_CHANGED))?;
263
264                let variable_str = $function(variable_str)?;
265
266                debug_assert_eq!(found_name, $name);
267                $variable
268                    .set(variable_str.trim().parse().error(OUTPUT_CHANGED)?)
269                    .map_err(|_| Error::new(OUTPUT_CHANGED))?;
270            }
271        };
272    }
273
274    let filesystem_usage = Command::new("btrfs")
275        .args(["filesystem", "usage", "--raw", path])
276        .output()
277        .await
278        .error("Failed to collect btrfs filesystem usage info")?
279        .stdout;
280
281    {
282        let final_total = OnceCell::new();
283        let final_used = OnceCell::new();
284        let final_free = OnceCell::new();
285
286        let mut lines = filesystem_usage.lines();
287        while let Some(line) = lines
288            .next_line()
289            .await
290            .error("Failed to read output of btrfs filesystem usage")?
291        {
292            let line = line.trim();
293
294            // See btrfs-filesystem(8) for an explanation for the rows.
295            get!(line, "Device size", final_total);
296            get!(line, "Used", final_used);
297            get!(@pre_op remove_estimate_min, line, "Free (estimated)", final_free);
298        }
299
300        Ok((
301            *final_total.get().ok_or(Error::new(OUTPUT_CHANGED))?,
302            *final_used.get().ok_or(Error::new(OUTPUT_CHANGED))?,
303            // HACK(@bpeetz): We also return the free disk space as the available one, because btrfs
304            // does not tell us which disk space is reserved for the fs. <2025-05-18>
305            *final_free.get().ok_or(Error::new(OUTPUT_CHANGED))?,
306            *final_free.get().ok_or(Error::new(OUTPUT_CHANGED))?,
307        ))
308    }
309}
310
311#[cfg(test)]
312mod tests {
313    use super::*;
314
315    #[test]
316    fn plan_declares_single_format_with_disk_drive_icon() {
317        let plan = prepare(&Config::default()).unwrap();
318        let format = plan.output("format").unwrap();
319        assert_eq!(format.single_icon("icon").unwrap(), "disk_drive");
320        assert!(format.format().contains_key("available"));
321        assert!(plan.output("format2").is_err());
322    }
323
324    #[test]
325    fn every_configured_format_is_declared() {
326        let config: Config =
327            toml::from_str(r#"format = [" $icon $available ", " $icon $available / $total "]"#)
328                .unwrap();
329        let plan = prepare(&config).unwrap();
330        let ids: Vec<_> = plan.outputs().map(|o| o.id()).collect();
331        assert_eq!(ids, ["format", "format2"]);
332        let second = plan.output("format2").unwrap();
333        assert!(second.format().contains_key("total"));
334        assert_eq!(second.single_icon("icon").unwrap(), "disk_drive");
335    }
336}