1pub fn detect_btrfs() -> Vec<String> {
21 #[cfg(target_os = "linux")]
22 {
23 detect_linux()
24 }
25
26 #[cfg(not(target_os = "linux"))]
27 {
28 Vec::new()
29 }
30}
31
32#[cfg(target_os = "linux")]
33fn detect_linux() -> Vec<String> {
34 let mounts = std::fs::read_to_string("/proc/mounts").unwrap_or_default();
35 let mut results = Vec::new();
36
37 for line in mounts.lines() {
38 let Some(entry) = parse_mount_line(line) else {
39 continue;
40 };
41 if entry.fs_type != "btrfs" {
42 continue;
43 }
44
45 if let Some(formatted) = format_btrfs_volume(entry.mount_point, entry.subvol) {
46 results.push(formatted);
47 }
48 }
49
50 results
51}
52
53#[cfg(target_os = "linux")]
54struct MountEntry<'a> {
55 mount_point: &'a str,
56 fs_type: &'a str,
57 subvol: Option<&'a str>,
58}
59
60#[cfg(target_os = "linux")]
62fn parse_mount_line(line: &str) -> Option<MountEntry<'_>> {
63 let parts: Vec<&str> = line.splitn(4, ' ').collect();
64 if parts.len() < 4 {
65 return None;
66 }
67 let mount_point = parts[1];
68 let fs_type = parts[2];
69 let options = parts[3].split(' ').next().unwrap_or("");
71 let subvol = options
72 .split(',')
73 .find_map(|opt| opt.strip_prefix("subvol="));
74
75 Some(MountEntry {
76 mount_point,
77 fs_type,
78 subvol,
79 })
80}
81
82#[cfg(target_os = "linux")]
84fn format_btrfs_volume(mount_point: &str, subvol: Option<&str>) -> Option<String> {
85 let label = std::process::Command::new("btrfs")
86 .args(["filesystem", "show", mount_point])
87 .output()
88 .ok()
89 .filter(|o| o.status.success())
90 .and_then(|o| parse_btrfs_label(&String::from_utf8_lossy(&o.stdout)));
91
92 let usage = std::process::Command::new("btrfs")
93 .args(["filesystem", "usage", "--raw", mount_point])
94 .output()
95 .ok()
96 .filter(|o| o.status.success())
97 .and_then(|o| parse_btrfs_usage(&String::from_utf8_lossy(&o.stdout)))?;
98
99 let (used, size) = usage;
100 let used_gb = used as f64 / 1_073_741_824.0;
101 let size_gb = size as f64 / 1_073_741_824.0;
102
103 let snapshots = count_snapshots(mount_point);
104
105 let name = match (label, subvol) {
106 (Some(label), Some(subvol)) => format!("{} ({}, subvol={})", label, mount_point, subvol),
107 (Some(label), None) => format!("{} ({})", label, mount_point),
108 (None, Some(subvol)) => format!("{} (subvol={})", mount_point, subvol),
109 (None, None) => mount_point.to_string(),
110 };
111
112 Some(match snapshots {
113 Some(n) => format!(
114 "{}: {:.1} / {:.1} GB used, {} snapshot{}",
115 name,
116 used_gb,
117 size_gb,
118 n,
119 if n == 1 { "" } else { "s" }
120 ),
121 None => format!("{}: {:.1} / {:.1} GB used", name, used_gb, size_gb),
122 })
123}
124
125#[cfg(target_os = "linux")]
130fn count_snapshots(mount_point: &str) -> Option<usize> {
131 let output = std::process::Command::new("btrfs")
132 .args(["subvolume", "list", "-s", mount_point])
133 .output()
134 .ok()?;
135 if !output.status.success() {
136 return None;
137 }
138 let text = String::from_utf8_lossy(&output.stdout);
139 Some(text.lines().filter(|l| !l.trim().is_empty()).count())
140}
141
142#[cfg(target_os = "linux")]
144fn parse_btrfs_label(text: &str) -> Option<String> {
145 let line = text.lines().next()?;
146 let start = line.find('\'')? + 1;
147 let rest = &line[start..];
148 let end = rest.find('\'')?;
149 let label = &rest[..end];
150 if label.is_empty() {
151 None
152 } else {
153 Some(label.to_string())
154 }
155}
156
157#[cfg(target_os = "linux")]
159fn parse_btrfs_usage(text: &str) -> Option<(u64, u64)> {
160 let mut size = None;
161 let mut used = None;
162
163 for line in text.lines() {
164 let trimmed = line.trim();
165 if let Some(rest) = trimmed.strip_prefix("Device size:") {
166 size = rest.trim().parse::<u64>().ok();
167 } else if let Some(rest) = trimmed.strip_prefix("Used:") {
168 used = rest.trim().parse::<u64>().ok();
169 }
170 }
171
172 Some((used?, size?))
173}
174
175#[cfg(test)]
176mod tests {
177 #[cfg(target_os = "linux")]
178 use super::{parse_btrfs_label, parse_btrfs_usage, parse_mount_line};
179
180 #[cfg(target_os = "linux")]
181 #[test]
182 fn test_parse_mount_line_btrfs_with_subvol() {
183 let line = "/dev/nvme0n1p3 /home btrfs rw,seclabel,relatime,compress=zstd:1,ssd,discard=async,space_cache=v2,subvolid=257,subvol=/home 0 0";
184 let entry = parse_mount_line(line).unwrap();
185 assert_eq!(entry.mount_point, "/home");
186 assert_eq!(entry.fs_type, "btrfs");
187 assert_eq!(entry.subvol, Some("/home"));
188 }
189
190 #[cfg(target_os = "linux")]
191 #[test]
192 fn test_parse_mount_line_two_subvols_same_device_both_kept() {
193 let root = "/dev/nvme0n1p3 / btrfs rw,subvolid=256,subvol=/root 0 0";
196 let home = "/dev/nvme0n1p3 /home btrfs rw,subvolid=257,subvol=/home 0 0";
197 let root_entry = parse_mount_line(root).unwrap();
198 let home_entry = parse_mount_line(home).unwrap();
199 assert_eq!(root_entry.mount_point, "/");
200 assert_eq!(root_entry.subvol, Some("/root"));
201 assert_eq!(home_entry.mount_point, "/home");
202 assert_eq!(home_entry.subvol, Some("/home"));
203 }
204
205 #[cfg(target_os = "linux")]
206 #[test]
207 fn test_parse_mount_line_non_btrfs() {
208 let line = "/dev/nvme0n1p1 /boot ext4 rw,seclabel,relatime 0 0";
209 let entry = parse_mount_line(line).unwrap();
210 assert_eq!(entry.fs_type, "ext4");
211 }
212
213 #[cfg(target_os = "linux")]
214 #[test]
215 fn test_parse_mount_line_no_subvol_option() {
216 let line = "/dev/sdb1 /mnt/data btrfs rw,relatime,space_cache=v2 0 0";
217 let entry = parse_mount_line(line).unwrap();
218 assert_eq!(entry.subvol, None);
219 }
220
221 #[cfg(target_os = "linux")]
222 #[test]
223 fn test_parse_btrfs_label() {
224 let text = "Label: 'root' uuid: 1234abcd-5678-90ef-1234-567890abcdef\n\tTotal devices 1 FS bytes used 5.02GiB\n";
225 assert_eq!(parse_btrfs_label(text), Some("root".to_string()));
226 }
227
228 #[cfg(target_os = "linux")]
229 #[test]
230 fn test_parse_btrfs_label_none() {
231 let text = "Label: none uuid: 1234abcd-5678-90ef-1234-567890abcdef\n";
232 assert_eq!(parse_btrfs_label(text), None);
233 }
234
235 #[cfg(target_os = "linux")]
236 #[test]
237 fn test_parse_btrfs_usage() {
238 let text = "Overall:\n Device size: 21474836480\n Device allocated: 5390387200\n Device unallocated: 16084449280\n Device missing: 0\n Used: 3576184832\n Free (estimated): 17650311168 (min: 17650311168)\n";
239 assert_eq!(
240 parse_btrfs_usage(text),
241 Some((3_576_184_832, 21_474_836_480))
242 );
243 }
244
245 #[cfg(target_os = "linux")]
246 #[test]
247 fn test_parse_btrfs_usage_missing_fields() {
248 let text = "Overall:\n Device size: 21474836480\n";
249 assert_eq!(parse_btrfs_usage(text), None);
250 }
251}