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