1use 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 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 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 Some("TiB") => Some(Prefix::Tebi),
135 Some("GiB") => Some(Prefix::Gibi),
136 Some("MiB") => Some(Prefix::Mebi),
137 Some("KiB") => Some(Prefix::Kibi),
138 Some("B") => Some(Prefix::One),
140 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 let alert_val_in_config_units = match unit {
177 Some(p) => p.apply(result),
178 None => percentage,
179 };
180
181 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 #[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 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 *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}