1use super::UnitMode;
16use crate::{
17 Format, RunContext, Runnable,
18 util::{SizeFormat, fmt_size},
19};
20use anyhow::{Context, Result};
21use btrfs_uapi::fiemap::file_extents;
22use clap::Parser;
23use cols::Cols;
24use serde::Serialize;
25use std::{
26 collections::HashSet,
27 fs::{self, File},
28 os::unix::{fs::MetadataExt, io::AsFd},
29 path::{Path, PathBuf},
30};
31
32#[derive(Parser, Debug)]
40pub struct FilesystemDuCommand {
41 #[clap(long, short, conflicts_with = "depth")]
43 pub summarize: bool,
44
45 #[clap(long, short = 'd', conflicts_with = "summarize")]
50 pub depth: Option<usize>,
51
52 #[clap(long, value_enum)]
56 pub sort: Option<DuSort>,
57
58 #[clap(flatten)]
59 pub units: UnitMode,
60
61 #[clap(required = true)]
63 pub paths: Vec<PathBuf>,
64}
65
66#[derive(Default, Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
68pub enum DuSort {
69 #[default]
71 Path,
72 Total,
74 Exclusive,
76 Shared,
78}
79
80struct DuEntry {
82 path: PathBuf,
83 total: u64,
84 shared: u64,
85 shared_extents: Vec<(u64, u64)>,
87 tree_children: Vec<DuRow>,
89}
90
91impl DuEntry {
92 fn exclusive(&self) -> u64 {
93 self.total.saturating_sub(self.shared)
94 }
95}
96
97fn sort_entries(entries: &mut [DuEntry], sort: DuSort) {
98 entries.sort_by(|a, b| match sort {
99 DuSort::Path => a.path.cmp(&b.path),
100 DuSort::Total => b.total.cmp(&a.total),
101 DuSort::Exclusive => b.exclusive().cmp(&a.exclusive()),
102 DuSort::Shared => b.shared.cmp(&a.shared),
103 });
104}
105
106impl Runnable for FilesystemDuCommand {
107 fn supported_formats(&self) -> &[Format] {
108 &[Format::Text, Format::Modern, Format::Json]
109 }
110
111 fn run(&self, ctx: &RunContext) -> Result<()> {
112 let mode = self.units.resolve();
113 let max_depth = if self.summarize { Some(0) } else { self.depth };
114
115 if self.sort.is_some() && ctx.format != Format::Modern {
116 anyhow::bail!("--sort is only supported with --format modern");
117 }
118 let sort = self.sort.unwrap_or(DuSort::Path);
119
120 match ctx.format {
121 Format::Modern => {
122 let mut trees: Vec<DuRow> = Vec::new();
123 for path in &self.paths {
124 let row = collect_tree(path, max_depth, sort, &mode)
125 .with_context(|| {
126 format!(
127 "cannot check space of '{}'",
128 path.display()
129 )
130 })?;
131 trees.push(row);
132 }
133 let mut out = std::io::stdout().lock();
134 let _ = DuRow::print_table(&trees, &mut out);
135 }
136 Format::Text => {
137 println!(
138 "{:>10} {:>10} {:>10} Filename",
139 "Total", "Exclusive", "Set shared"
140 );
141 for path in &self.paths {
142 print_top_level(path, max_depth, &mode).with_context(
143 || {
144 format!(
145 "cannot check space of '{}'",
146 path.display()
147 )
148 },
149 )?;
150 }
151 }
152 Format::Json => {
153 let mut all: Vec<DuEntryJson> = Vec::new();
154 for path in &self.paths {
155 collect_json(path, max_depth, &mut all)?;
156 }
157 crate::util::print_json("filesystem-du", &all)?;
158 }
159 }
160
161 Ok(())
162 }
163}
164
165#[derive(Clone, Cols)]
168struct DuRow {
169 #[column(header = "TOTAL", right)]
170 total: String,
171 #[column(header = "EXCL", right)]
172 exclusive: String,
173 #[column(header = "SET SHARED", right)]
174 set_shared: String,
175 #[column(tree, wrap)]
176 path: String,
177 #[column(children)]
178 children: Vec<Self>,
179}
180
181fn collect_tree(
183 path: &Path,
184 max_depth: Option<usize>,
185 sort: DuSort,
186 mode: &SizeFormat,
187) -> Result<DuRow> {
188 let mut seen: HashSet<(u64, u64)> = HashSet::new();
189 let mut shared_ranges: Vec<(u64, u64)> = Vec::new();
190
191 let meta = fs::symlink_metadata(path)
192 .with_context(|| format!("cannot stat '{}'", path.display()))?;
193
194 let root_dev = meta.dev();
195
196 let (total, file_shared, children) = if meta.is_file() {
197 if meta.len() == 0 {
199 (0, 0, Vec::new())
200 } else {
201 let file = File::open(path)
202 .with_context(|| format!("cannot open '{}'", path.display()))?;
203 let info = file_extents(file.as_fd()).map_err(|e| {
204 anyhow::anyhow!("fiemap failed on '{}': {e}", path.display())
205 })?;
206 shared_ranges.extend_from_slice(&info.shared_extents);
207 (info.total_bytes, info.shared_bytes, Vec::new())
208 }
209 } else if meta.is_dir() {
210 collect_dir_tree(
211 path,
212 root_dev,
213 &mut seen,
214 &mut shared_ranges,
215 max_depth,
216 0,
217 sort,
218 mode,
219 )?
220 } else {
221 (0, 0, Vec::new())
222 };
223
224 let set_shared = compute_set_shared(&mut shared_ranges);
225 let exclusive = total.saturating_sub(file_shared);
226
227 Ok(DuRow {
228 total: fmt_size(total, mode),
229 exclusive: fmt_size(exclusive, mode),
230 set_shared: fmt_size(set_shared, mode),
231 path: path.display().to_string(),
232 children,
233 })
234}
235
236#[allow(clippy::too_many_arguments)]
242fn collect_dir_tree(
243 dir: &Path,
244 root_dev: u64,
245 seen: &mut HashSet<(u64, u64)>,
246 shared_ranges: &mut Vec<(u64, u64)>,
247 max_depth: Option<usize>,
248 depth: usize,
249 sort: DuSort,
250 mode: &SizeFormat,
251) -> Result<(u64, u64, Vec<DuRow>)> {
252 let mut dir_total: u64 = 0;
253 let mut dir_shared: u64 = 0;
254 let show = max_depth.is_none_or(|m| depth < m);
255
256 let raw_items = collect_dir_entries(dir, root_dev, seen)?;
257 let mut entries = collect_entry_sizes(
258 &raw_items,
259 root_dev,
260 seen,
261 shared_ranges,
262 max_depth,
263 depth,
264 sort,
265 mode,
266 )?;
267
268 sort_entries(&mut entries, sort);
269
270 let mut children: Vec<DuRow> = Vec::new();
271 for e in &entries {
272 shared_ranges.extend_from_slice(&e.shared_extents);
273 dir_total += e.total;
274 dir_shared += e.shared;
275
276 if show {
277 let excl = e.exclusive();
278 children.push(DuRow {
279 total: fmt_size(e.total, mode),
280 exclusive: fmt_size(excl, mode),
281 set_shared: "-".to_string(),
282 path: path_name(&e.path),
283 children: e.tree_children.clone(),
284 });
285 }
286 }
287
288 Ok((dir_total, dir_shared, children))
289}
290
291#[allow(clippy::too_many_arguments)]
293fn collect_entry_sizes(
294 items: &[(PathBuf, fs::Metadata)],
295 root_dev: u64,
296 seen: &mut HashSet<(u64, u64)>,
297 shared_ranges: &mut Vec<(u64, u64)>,
298 max_depth: Option<usize>,
299 depth: usize,
300 sort: DuSort,
301 mode: &SizeFormat,
302) -> Result<Vec<DuEntry>> {
303 let mut entries = Vec::new();
304
305 for (entry_path, meta) in items {
306 if meta.is_file() {
307 if meta.len() == 0 {
311 entries.push(DuEntry {
312 path: entry_path.clone(),
313
314 total: 0,
315 shared: 0,
316 shared_extents: Vec::new(),
317 tree_children: Vec::new(),
318 });
319 continue;
320 }
321
322 let file = match File::open(entry_path) {
323 Ok(f) => f,
324 Err(e) => {
325 eprintln!(
326 "warning: cannot open '{}': {e}",
327 entry_path.display()
328 );
329 continue;
330 }
331 };
332
333 let info = match file_extents(file.as_fd()) {
334 Ok(v) => v,
335 Err(e) => {
336 eprintln!(
337 "warning: fiemap failed on '{}': {e}",
338 entry_path.display()
339 );
340 continue;
341 }
342 };
343
344 entries.push(DuEntry {
345 path: entry_path.clone(),
346 total: info.total_bytes,
347 shared: info.shared_bytes,
348 shared_extents: info.shared_extents,
349 tree_children: Vec::new(),
350 });
351 } else {
352 let (sub_total, sub_shared, sub_children) = collect_dir_tree(
353 entry_path,
354 root_dev,
355 seen,
356 shared_ranges,
357 max_depth,
358 depth + 1,
359 sort,
360 mode,
361 )?;
362
363 entries.push(DuEntry {
364 path: entry_path.clone(),
365 total: sub_total,
366 shared: sub_shared,
367 shared_extents: Vec::new(),
368 tree_children: sub_children,
369 });
370 }
371 }
372
373 Ok(entries)
374}
375
376fn print_top_level(
379 path: &Path,
380 max_depth: Option<usize>,
381 mode: &SizeFormat,
382) -> Result<()> {
383 let mut seen: HashSet<(u64, u64)> = HashSet::new();
384 let mut shared_ranges: Vec<(u64, u64)> = Vec::new();
385
386 let meta = fs::symlink_metadata(path)
387 .with_context(|| format!("cannot stat '{}'", path.display()))?;
388
389 let root_dev = meta.dev();
390
391 let (total, file_shared) = if meta.is_file() {
392 if meta.len() == 0 {
394 (0, 0)
395 } else {
396 let file = File::open(path)
397 .with_context(|| format!("cannot open '{}'", path.display()))?;
398 let info = file_extents(file.as_fd()).map_err(|e| {
399 anyhow::anyhow!("fiemap failed on '{}': {e}", path.display())
400 })?;
401 shared_ranges.extend_from_slice(&info.shared_extents);
402 (info.total_bytes, info.shared_bytes)
403 }
404 } else if meta.is_dir() {
405 print_walk_dir(
406 path,
407 root_dev,
408 &mut seen,
409 &mut shared_ranges,
410 max_depth,
411 0,
412 mode,
413 )?
414 } else {
415 (0, 0)
416 };
417
418 let set_shared = compute_set_shared(&mut shared_ranges);
419 let exclusive = total.saturating_sub(file_shared);
420
421 println!(
422 "{:>10} {:>10} {:>10} {}",
423 fmt_size(total, mode),
424 fmt_size(exclusive, mode),
425 fmt_size(set_shared, mode),
426 path.display()
427 );
428
429 Ok(())
430}
431
432#[allow(clippy::too_many_lines)]
433fn print_walk_dir(
434 dir: &Path,
435 root_dev: u64,
436 seen: &mut HashSet<(u64, u64)>,
437 shared_ranges: &mut Vec<(u64, u64)>,
438 max_depth: Option<usize>,
439 depth: usize,
440 mode: &SizeFormat,
441) -> Result<(u64, u64)> {
442 let mut dir_total: u64 = 0;
443 let mut dir_shared: u64 = 0;
444 let show = max_depth.is_none_or(|m| depth < m);
445
446 let entries = fs::read_dir(dir).with_context(|| {
447 format!("cannot read directory '{}'", dir.display())
448 })?;
449
450 for entry in entries {
451 let entry = entry.with_context(|| {
452 format!("error reading entry in '{}'", dir.display())
453 })?;
454 let entry_path = entry.path();
455
456 let meta = match fs::symlink_metadata(&entry_path) {
457 Ok(m) => m,
458 Err(e) => {
459 eprintln!(
460 "warning: cannot stat '{}': {e}",
461 entry_path.display()
462 );
463 continue;
464 }
465 };
466
467 if !meta.is_file() && !meta.is_dir() {
468 continue;
469 }
470
471 if meta.dev() != root_dev {
472 continue;
473 }
474
475 let key = (meta.dev(), meta.ino());
476 if !seen.insert(key) {
477 continue;
478 }
479
480 if meta.is_file() {
481 if meta.len() == 0 {
485 if show {
486 println!(
487 "{:>10} {:>10} {:>10} {}",
488 fmt_size(0, mode),
489 fmt_size(0, mode),
490 "-",
491 entry_path.display()
492 );
493 }
494 continue;
495 }
496
497 let file = match File::open(&entry_path) {
498 Ok(f) => f,
499 Err(e) => {
500 eprintln!(
501 "warning: cannot open '{}': {e}",
502 entry_path.display()
503 );
504 continue;
505 }
506 };
507
508 let info = match file_extents(file.as_fd()) {
509 Ok(v) => v,
510 Err(e) => {
511 eprintln!(
512 "warning: fiemap failed on '{}': {e}",
513 entry_path.display()
514 );
515 continue;
516 }
517 };
518
519 if show {
520 let excl = info.total_bytes.saturating_sub(info.shared_bytes);
521 println!(
522 "{:>10} {:>10} {:>10} {}",
523 fmt_size(info.total_bytes, mode),
524 fmt_size(excl, mode),
525 "-",
526 entry_path.display()
527 );
528 }
529
530 shared_ranges.extend_from_slice(&info.shared_extents);
531 dir_total += info.total_bytes;
532 dir_shared += info.shared_bytes;
533 } else {
534 let (sub_total, sub_shared) = print_walk_dir(
535 &entry_path,
536 root_dev,
537 seen,
538 shared_ranges,
539 max_depth,
540 depth + 1,
541 mode,
542 )?;
543
544 if show {
545 let excl = sub_total.saturating_sub(sub_shared);
546 println!(
547 "{:>10} {:>10} {:>10} {}",
548 fmt_size(sub_total, mode),
549 fmt_size(excl, mode),
550 "-",
551 entry_path.display()
552 );
553 }
554
555 dir_total += sub_total;
556 dir_shared += sub_shared;
557 }
558 }
559
560 Ok((dir_total, dir_shared))
561}
562
563fn collect_dir_entries(
567 dir: &Path,
568 root_dev: u64,
569 seen: &mut HashSet<(u64, u64)>,
570) -> Result<Vec<(PathBuf, fs::Metadata)>> {
571 let mut items = Vec::new();
572 let entries = fs::read_dir(dir).with_context(|| {
573 format!("cannot read directory '{}'", dir.display())
574 })?;
575
576 for entry in entries {
577 let entry = entry.with_context(|| {
578 format!("error reading entry in '{}'", dir.display())
579 })?;
580 let entry_path = entry.path();
581
582 let meta = match fs::symlink_metadata(&entry_path) {
583 Ok(m) => m,
584 Err(e) => {
585 eprintln!(
586 "warning: cannot stat '{}': {e}",
587 entry_path.display()
588 );
589 continue;
590 }
591 };
592
593 if !meta.is_file() && !meta.is_dir() {
594 continue;
595 }
596
597 if meta.dev() != root_dev {
598 continue;
599 }
600
601 let key = (meta.dev(), meta.ino());
602 if !seen.insert(key) {
603 continue;
604 }
605
606 items.push((entry_path, meta));
607 }
608
609 items.sort_by(|(a, _), (b, _)| a.cmp(b));
610 Ok(items)
611}
612
613fn path_name(path: &Path) -> String {
615 path.file_name().map_or_else(
616 || path.display().to_string(),
617 |n| n.to_string_lossy().into_owned(),
618 )
619}
620
621#[derive(Serialize)]
624struct DuEntryJson {
625 path: String,
626 total: u64,
627 exclusive: u64,
628 set_shared: Option<u64>,
630}
631
632fn collect_json(
634 path: &Path,
635 max_depth: Option<usize>,
636 out: &mut Vec<DuEntryJson>,
637) -> Result<()> {
638 let mut seen: HashSet<(u64, u64)> = HashSet::new();
639 let mut shared_ranges: Vec<(u64, u64)> = Vec::new();
640
641 let meta = fs::symlink_metadata(path)
642 .with_context(|| format!("cannot stat '{}'", path.display()))?;
643
644 let root_dev = meta.dev();
645
646 let (total, file_shared) = if meta.is_file() {
647 if meta.len() == 0 {
648 (0, 0)
649 } else {
650 let file = File::open(path)
651 .with_context(|| format!("cannot open '{}'", path.display()))?;
652 let info = file_extents(file.as_fd()).map_err(|e| {
653 anyhow::anyhow!("fiemap failed on '{}': {e}", path.display())
654 })?;
655 shared_ranges.extend_from_slice(&info.shared_extents);
656 (info.total_bytes, info.shared_bytes)
657 }
658 } else if meta.is_dir() {
659 collect_json_walk(
660 path,
661 root_dev,
662 &mut seen,
663 &mut shared_ranges,
664 max_depth,
665 0,
666 out,
667 )?
668 } else {
669 (0, 0)
670 };
671
672 let set_shared = compute_set_shared(&mut shared_ranges);
673 let exclusive = total.saturating_sub(file_shared);
674
675 out.push(DuEntryJson {
676 path: path.display().to_string(),
677 total,
678 exclusive,
679 set_shared: Some(set_shared),
680 });
681
682 Ok(())
683}
684
685#[allow(clippy::too_many_arguments)]
686fn collect_json_walk(
687 dir: &Path,
688 root_dev: u64,
689 seen: &mut HashSet<(u64, u64)>,
690 shared_ranges: &mut Vec<(u64, u64)>,
691 max_depth: Option<usize>,
692 depth: usize,
693 out: &mut Vec<DuEntryJson>,
694) -> Result<(u64, u64)> {
695 let mut dir_total: u64 = 0;
696 let mut dir_shared: u64 = 0;
697 let show = max_depth.is_none_or(|m| depth < m);
698
699 let entries = fs::read_dir(dir).with_context(|| {
700 format!("cannot read directory '{}'", dir.display())
701 })?;
702
703 for entry in entries {
704 let entry = entry.with_context(|| {
705 format!("error reading entry in '{}'", dir.display())
706 })?;
707 let entry_path = entry.path();
708
709 let meta = match fs::symlink_metadata(&entry_path) {
710 Ok(m) => m,
711 Err(e) => {
712 eprintln!(
713 "warning: cannot stat '{}': {e}",
714 entry_path.display()
715 );
716 continue;
717 }
718 };
719
720 if !meta.is_file() && !meta.is_dir() {
721 continue;
722 }
723
724 if meta.dev() != root_dev {
725 continue;
726 }
727
728 let key = (meta.dev(), meta.ino());
729 if !seen.insert(key) {
730 continue;
731 }
732
733 if meta.is_file() {
734 if meta.len() == 0 {
738 if show {
739 out.push(DuEntryJson {
740 path: entry_path.display().to_string(),
741 total: 0,
742 exclusive: 0,
743 set_shared: None,
744 });
745 }
746 continue;
747 }
748
749 let file = match File::open(&entry_path) {
750 Ok(f) => f,
751 Err(e) => {
752 eprintln!(
753 "warning: cannot open '{}': {e}",
754 entry_path.display()
755 );
756 continue;
757 }
758 };
759
760 let info = match file_extents(file.as_fd()) {
761 Ok(v) => v,
762 Err(e) => {
763 eprintln!(
764 "warning: fiemap failed on '{}': {e}",
765 entry_path.display()
766 );
767 continue;
768 }
769 };
770
771 if show {
772 let excl = info.total_bytes.saturating_sub(info.shared_bytes);
773 out.push(DuEntryJson {
774 path: entry_path.display().to_string(),
775 total: info.total_bytes,
776 exclusive: excl,
777 set_shared: None,
778 });
779 }
780
781 shared_ranges.extend_from_slice(&info.shared_extents);
782 dir_total += info.total_bytes;
783 dir_shared += info.shared_bytes;
784 } else {
785 let (sub_total, sub_shared) = collect_json_walk(
786 &entry_path,
787 root_dev,
788 seen,
789 shared_ranges,
790 max_depth,
791 depth + 1,
792 out,
793 )?;
794
795 if show {
796 let excl = sub_total.saturating_sub(sub_shared);
797 out.push(DuEntryJson {
798 path: entry_path.display().to_string(),
799 total: sub_total,
800 exclusive: excl,
801 set_shared: None,
802 });
803 }
804
805 dir_total += sub_total;
806 dir_shared += sub_shared;
807 }
808 }
809
810 Ok((dir_total, dir_shared))
811}
812
813fn compute_set_shared(ranges: &mut [(u64, u64)]) -> u64 {
818 if ranges.is_empty() {
819 return 0;
820 }
821 ranges.sort_unstable();
822
823 let mut total = 0u64;
824 let (mut cur_start, mut cur_end) = ranges[0];
825
826 for &(start, end) in &ranges[1..] {
827 if start <= cur_end {
828 if end > cur_end {
829 cur_end = end;
830 }
831 } else {
832 total += cur_end - cur_start;
833 cur_start = start;
834 cur_end = end;
835 }
836 }
837 total += cur_end - cur_start;
838 total
839}