Repository: scullionw/dirstat-rs Branch: master Commit: 41d46b7bd347 Files: 8 Total size: 18.0 KB Directory structure: gitextract_khj5dz01/ ├── .github/ │ └── workflows/ │ └── release.yaml ├── .gitignore ├── Cargo.toml ├── LICENSE ├── README.md └── src/ ├── bin/ │ └── main.rs ├── ffi.rs └── lib.rs ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/workflows/release.yaml ================================================ name: Release on: push: tags: - "v*" jobs: release: runs-on: macos-11 steps: - uses: actions/checkout@v2 - name: Get version id: get_version run: echo ::set-output name=version::${GITHUB_REF/refs\/tags\//} - uses: actions-rs/toolchain@v1 with: toolchain: stable target: aarch64-apple-darwin - uses: actions-rs/cargo@v1 with: command: build args: --release --target=x86_64-apple-darwin - uses: actions-rs/cargo@v1 with: command: build args: --release --target=aarch64-apple-darwin - uses: actions-rs/cargo@v1 with: command: publish args: --token=${{ secrets.CRATES_TOKEN }} - name: Universal binary run: | mkdir -p target/universal-apple-darwin/release lipo -create -output target/universal-apple-darwin/release/ds target/aarch64-apple-darwin/release/ds target/x86_64-apple-darwin/release/ds - name: Create tar run: | tar -C ./target/universal-apple-darwin/release/ -czf dirstat-rs-${{ steps.get_version.outputs.version }}-universal-apple-darwin.tar.gz ./ds - name: Set SHA id: shasum run: | echo ::set-output name=sha::"$(shasum -a 256 ./dirstat-rs-${{ steps.get_version.outputs.version }}-universal-apple-darwin.tar.gz | awk '{printf $1}')" - name: Create Release id: create_release uses: actions/create-release@v1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: tag_name: ${{ github.ref }} release_name: Release ${{ github.ref }} draft: false prerelease: false - name: Upload Release Asset uses: actions/upload-release-asset@v1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: upload_url: ${{ steps.create_release.outputs.upload_url }} asset_path: dirstat-rs-${{ steps.get_version.outputs.version }}-universal-apple-darwin.tar.gz asset_name: dirstat-rs-${{ steps.get_version.outputs.version }}-universal-apple-darwin.tar.gz asset_content_type: application/gzip - uses: mislav/bump-homebrew-formula-action@v1 if: "!contains(github.ref, '-')" with: formula-name: dirstat-rs formula-path: Formula/dirstat-rs.rb homebrew-tap: scullionw/homebrew-tap base-branch: main download-url: https://github.com/scullionw/dirstat-rs/releases/download/${{ steps.get_version.outputs.version }}/dirstat-rs-${{ steps.get_version.outputs.version }}-universal-apple-darwin.tar.gz download-sha256: ${{ steps.shasum.outputs.sha }} commit-message: | {{formulaName}} {{version}} env: COMMITTER_TOKEN: ${{ secrets.BREW_TOKEN }} ================================================ FILE: .gitignore ================================================ /target **/*.rs.bk # Created by https://www.gitignore.io/api/linux,macos,windows # Edit at https://www.gitignore.io/?templates=linux,macos,windows ### Linux ### *~ # temporary files which can be created if a process still has a handle open of a deleted file .fuse_hidden* # KDE directory preferences .directory # Linux trash folder which might appear on any partition or disk .Trash-* # .nfs files are created when an open file is removed but is still being accessed .nfs* ### macOS ### # General .DS_Store .AppleDouble .LSOverride # Icon must end with two \r Icon # Thumbnails ._* # Files that might appear in the root of a volume .DocumentRevisions-V100 .fseventsd .Spotlight-V100 .TemporaryItems .Trashes .VolumeIcon.icns .com.apple.timemachine.donotpresent # Directories potentially created on remote AFP share .AppleDB .AppleDesktop Network Trash Folder Temporary Items .apdisk ### Windows ### # Windows thumbnail cache files Thumbs.db ehthumbs.db ehthumbs_vista.db # Dump file *.stackdump # Folder config file [Dd]esktop.ini # Recycle Bin used on file shares $RECYCLE.BIN/ # Windows Installer files *.cab *.msi *.msix *.msm *.msp # Windows shortcuts *.lnk # End of https://www.gitignore.io/api/linux,macos,windows ================================================ FILE: Cargo.toml ================================================ [package] name = "dirstat-rs" version = "0.3.8" authors = ["scullionw "] edition = "2018" license = "MIT" readme = "README.md" description = "A disk usage cli similar to windirstat" repository = "https://github.com/scullionw/dirstat-rs" keywords = ["cli", "disk", "usage", "tree", "windirstat"] categories = ["command-line-utilities"] [dependencies] structopt = "0.2.18" rayon = "1.5.1" pretty-bytes = "0.2.2" termcolor = "1.1.2" atty = "0.2.14" serde = { version = "1.0.131", features = ["derive"] } serde_json = "1.0.73" [target.'cfg(windows)'.dependencies] winapi-util = "0.1.2" [target.'cfg(windows)'.dependencies.winapi] version = "0.3.7" features = ["winerror"] [profile.release] lto = 'fat' codegen-units = 1 incremental = false [[bin]] bench = false path = "src/bin/main.rs" name = "ds" ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2019 William Scullion Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # dirstat-rs Fast, cross-platform disk usage CLI [![Crates.io](https://img.shields.io/crates/v/dirstat-rs.svg)](https://crates.io/crates/dirstat-rs) [![Docs.rs](https://docs.rs/dirstat-rs/badge.svg)](https://docs.rs/dirstat-rs/) ![Language](https://img.shields.io/badge/language-rust-orange) ![Platforms](https://img.shields.io/badge/platforms-Windows%2C%20macOS%20and%20Linux-blue) ![License](https://img.shields.io/github/license/scullionw/dirstat-rs) ![](demo/ds_demo.gif) 2X faster than du 4X faster than ncdu, dutree, dua, du-dust 6X faster than windirstat (On 4-core hyperthreaded cpu) # Installation ## Homebrew (macOS only) brew tap scullionw/tap brew install dirstat-rs ## Or if you prefer compiling yourself ### from crates.io: cargo install dirstat-rs ### or latest from git: cargo install --git "https://github.com/scullionw/dirstat-rs" ### or from source: cargo build --release sudo chmod +x /target/release/ds sudo cp /target/release/ds /usr/local/bin/ # Usage ### Current directory $ ds ### Specific path $ ds PATH ### Choose depth $ ds -d 3 ### Show apparent file size $ ds -a PATH ### Override minimum size threshold $ ds -m 0.2 PATH ================================================ FILE: src/bin/main.rs ================================================ use atty::Stream; use dirstat_rs::{DiskItem, FileInfo}; use pretty_bytes::converter::convert as pretty_bytes; use std::env; use std::error::Error; use std::io; use std::io::Write; use std::path::PathBuf; use structopt::StructOpt; use termcolor::{Buffer, BufferWriter, Color, ColorChoice, ColorSpec, WriteColor}; const INDENT_COLOR: Option = Some(Color::Rgb(75, 75, 75)); mod shape { pub const INDENT: &str = "│"; pub const _LAST_WITH_CHILDREN: &str = "└─┬"; pub const LAST: &str = "└──"; pub const ITEM: &str = "├──"; pub const _ITEM_WITH_CHILDREN: &str = "├─┬"; pub const SPACING: &str = "──"; } fn main() -> Result<(), Box> { let config = Config::from_args(); let current_dir = env::current_dir()?; let target_dir = config.target_dir.as_ref().unwrap_or(¤t_dir); let file_info = FileInfo::from_path(&target_dir, config.apparent)?; let color_choice = if atty::is(Stream::Stdout) { ColorChoice::Auto } else { ColorChoice::Never }; let stdout = BufferWriter::stdout(color_choice); let mut buffer = stdout.buffer(); if !config.json { println!("\nAnalyzing: {}\n", target_dir.display()) }; let analysed = match file_info { FileInfo::Directory { volume_id } => { DiskItem::from_analyze(&target_dir, config.apparent, volume_id)? } _ => return Err(format!("{} is not a directory!", target_dir.display()).into()), }; if config.json { let serialized = serde_json::to_string(&analysed)?; writeln!(&mut buffer, "{}", serialized)?; } else { show(&analysed, &config, &DisplayInfo::new(), &mut buffer)?; } stdout.print(&buffer)?; Ok(()) } fn show(item: &DiskItem, conf: &Config, info: &DisplayInfo, buffer: &mut Buffer) -> io::Result<()> { // Show self show_item(item, &info, buffer)?; // Recursively show children if info.level < conf.max_depth { if let Some(children) = &item.children { let children = children .iter() .map(|child| (child, size_fraction(child, item))) .filter(|&(_, fraction)| fraction > conf.min_percent) .collect::>(); if let Some((last_child, children)) = children.split_last() { for &(child, fraction) in children.iter() { show(child, conf, &info.add_item(fraction), buffer)?; } let &(child, fraction) = last_child; show(child, conf, &info.add_last(fraction), buffer)?; } } } Ok(()) } fn show_item(item: &DiskItem, info: &DisplayInfo, buffer: &mut Buffer) -> io::Result<()> { // Indentation buffer.set_color(ColorSpec::new().set_fg(INDENT_COLOR))?; write!(buffer, "{}{}", info.indents, info.prefix())?; // Percentage buffer.set_color(ColorSpec::new().set_fg(info.color()))?; write!(buffer, " {} ", format!("{:.2}%", info.fraction))?; // Disk size buffer.reset()?; write!(buffer, "[{}]", pretty_bytes(item.disk_size as f64),)?; // Arrow buffer.set_color(ColorSpec::new().set_fg(INDENT_COLOR))?; write!(buffer, " {} ", shape::SPACING)?; // Name buffer.reset()?; writeln!(buffer, "{}", item.name)?; Ok(()) } fn size_fraction(child: &DiskItem, parent: &DiskItem) -> f64 { 100.0 * (child.disk_size as f64 / parent.disk_size as f64) } #[derive(Debug, Clone)] struct DisplayInfo { fraction: f64, level: usize, last: bool, indents: String, } impl DisplayInfo { fn new() -> Self { Self { fraction: 100.0, level: 0, last: true, indents: String::new(), } } // TODO: Consume or mut instead of cloning fn add_item(&self, fraction: f64) -> Self { Self { fraction, level: self.level + 1, last: false, indents: self.indents.clone() + self.indent() + " ", } } fn add_last(&self, fraction: f64) -> Self { Self { fraction, level: self.level + 1, last: true, indents: self.indents.clone() + self.indent() + " ", } } fn indent(&self) -> &'static str { if self.last { " " } else { shape::INDENT } } fn prefix(&self) -> &'static str { if self.last { shape::LAST } else { shape::ITEM } } fn color(&self) -> Option { if self.level == 0 { Some(Color::Green) } else if self.fraction > 20.0 { Some(Color::Red) } else { Some(Color::Cyan) } } } #[derive(StructOpt)] struct Config { #[structopt(short = "d", default_value = "1")] /// Maximum recursion depth in directory. max_depth: usize, #[structopt( short = "m", default_value = "0.1", parse(try_from_str = "parse_percent") )] /// Threshold that determines if entry is worth /// being shown. Between 0-100 % of dir size. min_percent: f64, #[structopt(parse(from_os_str))] target_dir: Option, #[structopt(short = "a")] /// Show apparent file size. /// /// This reports logical file length instead of allocated size on disk. apparent: bool, #[structopt(short = "j")] /// Output sorted json. json: bool, } fn parse_percent(src: &str) -> Result> { let num = src.parse::()?; if num >= 0.0 && num <= 100.0 { Ok(num) } else { Err("Percentage must be in range [0, 100].".into()) } } ================================================ FILE: src/ffi.rs ================================================ #![cfg(windows)] use std::error::Error; use std::io; use std::iter::once; use std::os::windows::ffi::OsStrExt; use std::path::Path; use winapi::shared::winerror::NO_ERROR; use winapi::um::errhandlingapi::GetLastError; use winapi::um::fileapi::GetCompressedFileSizeW; use winapi::um::fileapi::INVALID_FILE_SIZE; pub fn compressed_size(path: &Path) -> Result> { let wide: Vec = path.as_os_str().encode_wide().chain(once(0)).collect(); let mut high: u32 = 0; // TODO: Deal with max path size let low = unsafe { GetCompressedFileSizeW(wide.as_ptr(), &mut high) }; if low == INVALID_FILE_SIZE { let err = get_last_error(); if err != NO_ERROR { return Err(io::Error::last_os_error().into()); } } Ok(u64::from(high) << 32 | u64::from(low)) } fn get_last_error() -> u32 { unsafe { GetLastError() } } ================================================ FILE: src/lib.rs ================================================ use rayon::prelude::*; use serde::Serialize; use std::error::Error; use std::ffi::OsStr; use std::fs; use std::path::Path; mod ffi; #[derive(Serialize)] pub struct DiskItem { pub name: String, pub disk_size: u64, pub children: Option>, } impl DiskItem { pub fn from_analyze( path: &Path, apparent: bool, root_dev: u64, ) -> Result> { let name = path .file_name() .unwrap_or(&OsStr::new(".")) .to_string_lossy() .to_string(); let file_info = FileInfo::from_path(path, apparent)?; match file_info { FileInfo::Directory { volume_id } => { if volume_id != root_dev { return Err("Filesystem boundary crossed".into()); } let sub_entries = fs::read_dir(path)? .filter_map(Result::ok) .collect::>(); let mut sub_items = sub_entries .par_iter() .filter_map(|entry| { DiskItem::from_analyze(&entry.path(), apparent, root_dev).ok() }) .collect::>(); sub_items.sort_unstable_by(|a, b| a.disk_size.cmp(&b.disk_size).reverse()); Ok(DiskItem { name, disk_size: sub_items.iter().map(|di| di.disk_size).sum(), children: Some(sub_items), }) } FileInfo::File { size, .. } => Ok(DiskItem { name, disk_size: size, children: None, }), } } } pub enum FileInfo { File { size: u64, volume_id: u64 }, Directory { volume_id: u64 }, } impl FileInfo { #[cfg(unix)] pub fn from_path(path: &Path, apparent: bool) -> Result> { use std::os::unix::fs::MetadataExt; let md = path.symlink_metadata()?; if md.is_dir() { Ok(FileInfo::Directory { volume_id: md.dev(), }) } else { let size = if apparent { md.len() } else { md.blocks() * 512 }; Ok(FileInfo::File { size, volume_id: md.dev(), }) } } #[cfg(windows)] pub fn from_path(path: &Path, apparent: bool) -> Result> { use winapi_util::{file, Handle}; const FILE_ATTRIBUTE_DIRECTORY: u64 = 0x10; let h = Handle::from_path_any(path)?; let md = file::information(h)?; if md.file_attributes() & FILE_ATTRIBUTE_DIRECTORY != 0 { Ok(FileInfo::Directory { volume_id: md.volume_serial_number(), }) } else { let size = if apparent { md.file_size() } else { ffi::compressed_size(path)? }; Ok(FileInfo::File { size, volume_id: md.volume_serial_number(), }) } } } #[cfg(all(test, unix))] mod tests { use super::FileInfo; use std::error::Error; use std::fs::{self, File}; use std::time::{SystemTime, UNIX_EPOCH}; #[test] fn apparent_size_uses_logical_file_length() -> Result<(), Box> { let dir = std::env::temp_dir().join(format!( "dirstat-rs-{}", SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos() )); fs::create_dir(&dir)?; let path = dir.join("sparse.bin"); let file = File::create(&path)?; let sparse_len = 1024 * 1024; file.set_len(sparse_len)?; drop(file); let apparent_size = match FileInfo::from_path(&path, true)? { FileInfo::File { size, .. } => size, FileInfo::Directory { .. } => panic!("test path should be a file"), }; let disk_size = match FileInfo::from_path(&path, false)? { FileInfo::File { size, .. } => size, FileInfo::Directory { .. } => panic!("test path should be a file"), }; fs::remove_file(&path)?; fs::remove_dir(&dir)?; assert_eq!(apparent_size, sparse_len); assert!(disk_size <= apparent_size); Ok(()) } }