diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..5a7e691 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,39 @@ +name: CI + +on: + push: + branches: [master] + pull_request: + branches: [master] + +env: + CARGO_TERM_COLOR: always + +jobs: + test: + name: Test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - run: cargo test + + fmt: + name: Format + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt + - run: cargo fmt --check + + clippy: + name: Clippy + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy + - run: cargo clippy --all-targets -- -D warnings diff --git a/Cargo.lock b/Cargo.lock index f0d27ba..9faf513 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,598 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anes" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "cast" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstyle", + "clap_lex", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "criterion" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2b12d017a929603d80db1831cd3a24082f8137ce19c69e6447f54f5fc8d692f" +dependencies = [ + "anes", + "cast", + "ciborium", + "clap", + "criterion-plot", + "is-terminal", + "itertools", + "num-traits", + "once_cell", + "oorandom", + "plotters", + "rayon", + "regex", + "serde", + "serde_derive", + "serde_json", + "tinytemplate", + "walkdir", +] + +[[package]] +name = "criterion-plot" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1" +dependencies = [ + "cast", + "itertools", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "is-terminal" +version = "0.4.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" +dependencies = [ + "hermit-abi", + "libc", + "windows-sys", +] + +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "oorandom" +version = "11.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "plotters" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747" +dependencies = [ + "num-traits", + "plotters-backend", + "plotters-svg", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "plotters-backend" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a" + +[[package]] +name = "plotters-svg" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670" +dependencies = [ + "plotters-backend", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + [[package]] name = "shellist" version = "0.1.0" +dependencies = [ + "criterion", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tinytemplate" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml index b174e71..54c56a0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,5 +2,22 @@ name = "shellist" version = "0.1.0" edition = "2024" +description = "Shell history analysis. Parses .bash_history, counts commands, and ranks by frequency." +license = "MIT" +repository = "https://github.com/ultraelectronica/shellist" +documentation = "https://docs.rs/shellist" +keywords = ["shell", "history", "bash", "cli", "analytics"] +categories = ["command-line-utilities"] + +[[bin]] +name = "shellist" +path = "src/main.rs" [dependencies] + +[dev-dependencies] +criterion = { version = "0.5", features = ["html_reports"] } + +[[bench]] +name = "pipeline" +harness = false diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 1dbbfff..ebd3571 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -67,10 +67,10 @@ **Goal:** Correctness. -- [ ] Unit tests for parsing -- [ ] Unit tests for counting -- [ ] Unit tests for sorting -- [ ] Integration test: `analyze("ls\nls\ngit\n")` → `ls` ranked first +- [X] Unit tests for parsing +- [X] Unit tests for counting +- [X] Unit tests for sorting +- [X] Integration test: `analyze("ls\nls\ngit\n")` → `ls` ranked first --- @@ -78,9 +78,9 @@ **Goal:** Handle large history files. -- [ ] Avoid unnecessary allocations -- [ ] Use iterators over cloning -- [ ] Benchmark with large inputs +- [X] Avoid unnecessary allocations +- [X] Use iterators over cloning +- [X] Benchmark with large inputs --- @@ -88,21 +88,21 @@ **Goal:** Usable and readable. -- [ ] `///` doc comments on all public items -- [ ] Example usage in doc comments -- [ ] Crate-level documentation +- [X] `///` doc comments on all public items +- [X] Example usage in doc comments +- [X] Crate-level documentation --- -## Phase 10: CLI Layer (Optional) +## Phase 10: CLI Layer **Goal:** Standalone binary. -- [ ] Argument parsing (`std::env::args`) -- [ ] Pretty output: +- [X] Argument parsing (`std::env::args`) +- [X] Pretty output: ``` 1. ls 120 2. git 95 3. cd 80 ``` -- [ ] Depend on `shellist` library via path +- [X] Depend on `shellist` library via path diff --git a/README.md b/README.md index 686482f..eccdaf1 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,84 @@ # Shellist -A Rust library that parses `.bash_history`, shows your most used commands, and ranks them by frequency. +Parse `.bash_history`, count commands, rank by frequency. Library and CLI. -## What it does +## Install -- Parses `.bash_history` files -- Extracts and counts command usage -- Ranks commands by frequency -- Supports filtering, top-N, and thresholds +```sh +cargo install shellist +``` + +## CLI + +```sh +shellist # reads ~/.bash_history, prints ranked table +shellist --top 5 # top 5 only +shellist --ignore ls,cd # exclude ls and cd +shellist --min 3 # only commands used ≥ 3 times +shellist --path ./my_history.txt +``` + +Options: + +| Flag | Description | +|-----------------|--------------------------------------| +| `--top N` | Show top N commands | +| `--ignore X,Y` | Exclude commands (comma-separated) | +| `--min N` | Only commands with count ≥ N | +| `--path PATH` | Read from PATH (default: ~/.bash_history) | +| `--help` | Print help | + +Example output: + +``` +Rank Command Count +---- ------- ----- + 1 ls 120 + 2 git 95 + 3 cd 80 +``` -## Usage +## Library ```rust -use shellist::analyze; +use shellist::{analyze, parse_history, count_commands, rank_commands, + top_n, filter_commands, filter_by_min_frequency, + load_history_file, default_history_path}; + +// Full pipeline +let ranked = analyze("ls\ngit\nls\ncd\ngit\nls"); +assert_eq!(ranked, vec![ + ("ls".to_string(), 3), + ("git".to_string(), 2), + ("cd".to_string(), 1), +]); + +// Step by step +let entries = parse_history("git push\ncd /\ngit commit"); +let counts = count_commands(&entries); +let ranked = rank_commands(counts); +let top = top_n(ranked, 2); +let filtered = filter_commands(top, &["cd"]); + +// From file +let content = load_history_file("/home/user/.bash_history")?; +``` + +## Benchmarks + +```sh +cargo bench +``` + +Runs criterion benchmarks on `analyze()` with 100, 1k, 10k, and 100k history entries. Results +are written to `target/criterion/` with HTML reports. + +To compare against a baseline: -let result = analyze("ls\ngit\nls\ncd\ngit\nls"); -// [("ls", 3), ("git", 2), ("cd", 1)] +```sh +cargo bench -- --save-baseline before +# make changes... +cargo bench -- --baseline before ``` ## License diff --git a/benches/pipeline.rs b/benches/pipeline.rs new file mode 100644 index 0000000..ef02a06 --- /dev/null +++ b/benches/pipeline.rs @@ -0,0 +1,43 @@ +use criterion::{Criterion, black_box, criterion_group, criterion_main}; + +fn generate_history(size: usize) -> String { + let commands = [ + "ls", "git", "cd", "cargo", "docker", "kubectl", "vim", "cat", "echo", "ssh", + ]; + let mut lines = Vec::with_capacity(size); + for i in 0..size { + let cmd = commands[i % commands.len()]; + let suffix = if i % 3 == 0 { + " --verbose --long-flag --another=value" + } else { + "" + }; + lines.push(format!("{cmd}{suffix}")); + } + lines.join("\n") +} + +fn bench_analyze(c: &mut Criterion) { + let input_100 = generate_history(100); + let input_1k = generate_history(1_000); + let input_10k = generate_history(10_000); + let input_100k = generate_history(100_000); + + let mut group = c.benchmark_group("analyze"); + group.bench_function("100 entries", |b| { + b.iter(|| shellist::analyze(black_box(&input_100))) + }); + group.bench_function("1k entries", |b| { + b.iter(|| shellist::analyze(black_box(&input_1k))) + }); + group.bench_function("10k entries", |b| { + b.iter(|| shellist::analyze(black_box(&input_10k))) + }); + group.bench_function("100k entries", |b| { + b.iter(|| shellist::analyze(black_box(&input_100k))) + }); + group.finish(); +} + +criterion_group!(benches, bench_analyze); +criterion_main!(benches); diff --git a/src/aggregators/command_counter.rs b/src/aggregators/command_counter.rs index 74d0a5a..484d448 100644 --- a/src/aggregators/command_counter.rs +++ b/src/aggregators/command_counter.rs @@ -2,8 +2,22 @@ use std::collections::HashMap; use crate::models::HistoryEntry; -// Count how many times each command was run. -// Normalizes to lowercase so "Git" and "git" don't get counted separately. +/// Count how many times each command appears. +/// +/// Normalizes to lowercase so `Git` and `git` merge into one count. +/// Skips empty commands. +/// +/// ```rust +/// use shellist::{HistoryEntry, count_commands}; +/// let entries = [ +/// HistoryEntry::new("ls -la", "ls"), +/// HistoryEntry::new("ls", "ls"), +/// HistoryEntry::new("Git status", "Git"), +/// ]; +/// let counts = count_commands(&entries); +/// assert_eq!(counts.get("ls"), Some(&2)); +/// assert_eq!(counts.get("git"), Some(&1)); +/// ``` pub fn count_commands(entries: &[HistoryEntry]) -> HashMap { let mut counts = HashMap::new(); diff --git a/src/aggregators/command_filter.rs b/src/aggregators/command_filter.rs index 1fd24f5..f038854 100644 --- a/src/aggregators/command_filter.rs +++ b/src/aggregators/command_filter.rs @@ -1,11 +1,28 @@ use std::collections::HashSet; -// Keep only the top N entries from a ranked list. +/// Keep only the top N entries. +/// +/// If N exceeds the list length, returns all entries. Zero returns an empty vec. +/// +/// ```rust +/// let commands = vec![("ls".into(), 10), ("git".into(), 5), ("cd".into(), 3)]; +/// let top = shellist::top_n(commands, 2); +/// assert_eq!(top.len(), 2); +/// assert_eq!(top[0], ("ls".to_string(), 10)); +/// ``` pub fn top_n(commands: Vec<(String, usize)>, n: usize) -> Vec<(String, usize)> { commands.into_iter().take(n).collect() } -// Drop commands whose name matches anything in the ignore list. +/// Remove commands whose name is in the ignore list. +/// +/// Accepts `&[String]`, `&[&str]`, or any `AsRef`. +/// +/// ```rust +/// let commands = vec![("ls".into(), 10), ("git".into(), 5), ("cd".into(), 3)]; +/// let filtered = shellist::filter_commands(commands, &["git", "cd"]); +/// assert_eq!(filtered, vec![("ls".to_string(), 10)]); +/// ``` pub fn filter_commands>( commands: Vec<(String, usize)>, ignore: &[S], @@ -17,11 +34,14 @@ pub fn filter_commands>( .collect() } -// Drop commands below a minimum frequency threshold. -pub fn filter_by_min_frequency( - commands: Vec<(String, usize)>, - min: usize, -) -> Vec<(String, usize)> { +/// Remove commands whose count is below the given minimum. +/// +/// ```rust +/// let commands = vec![("ls".into(), 10), ("git".into(), 5), ("cd".into(), 1)]; +/// let frequent = shellist::filter_by_min_frequency(commands, 2); +/// assert_eq!(frequent.len(), 2); +/// ``` +pub fn filter_by_min_frequency(commands: Vec<(String, usize)>, min: usize) -> Vec<(String, usize)> { commands .into_iter() .filter(|(_, count)| *count >= min) @@ -34,11 +54,7 @@ mod tests { #[test] fn top_n_returns_first_n() { - let commands = vec![ - ("ls".into(), 10), - ("git".into(), 5), - ("cd".into(), 3), - ]; + let commands = vec![("ls".into(), 10), ("git".into(), 5), ("cd".into(), 3)]; let result = top_n(commands, 2); assert_eq!(result.len(), 2); assert_eq!(result[0], ("ls".into(), 10)); @@ -68,11 +84,7 @@ mod tests { #[test] fn filter_commands_removes_matching() { - let commands = vec![ - ("ls".into(), 10), - ("git".into(), 5), - ("cd".into(), 3), - ]; + let commands = vec![("ls".into(), 10), ("git".into(), 5), ("cd".into(), 3)]; let result = filter_commands(commands, &["git".to_string()]); assert_eq!(result.len(), 2); assert_eq!(result[0].0, "ls"); @@ -81,11 +93,7 @@ mod tests { #[test] fn filter_commands_with_str_slices() { - let commands = vec![ - ("ls".into(), 10), - ("git".into(), 5), - ("cd".into(), 3), - ]; + let commands = vec![("ls".into(), 10), ("git".into(), 5), ("cd".into(), 3)]; let result = filter_commands(commands, &["git", "cd"]); assert_eq!(result.len(), 1); assert_eq!(result[0].0, "ls"); @@ -93,10 +101,7 @@ mod tests { #[test] fn filter_commands_nothing_ignored() { - let commands = vec![ - ("ls".into(), 10), - ("git".into(), 5), - ]; + let commands = vec![("ls".into(), 10), ("git".into(), 5)]; let result: Vec<(String, usize)> = filter_commands(commands, &[] as &[&str]); assert_eq!(result.len(), 2); } @@ -117,11 +122,7 @@ mod tests { #[test] fn min_frequency_filters_below_threshold() { - let commands = vec![ - ("ls".into(), 10), - ("git".into(), 5), - ("cd".into(), 1), - ]; + let commands = vec![("ls".into(), 10), ("git".into(), 5), ("cd".into(), 1)]; let result = filter_by_min_frequency(commands, 2); assert_eq!(result.len(), 2); assert_eq!(result[0].0, "ls"); @@ -130,10 +131,7 @@ mod tests { #[test] fn min_frequency_exact_threshold() { - let commands = vec![ - ("ls".into(), 3), - ("git".into(), 2), - ]; + let commands = vec![("ls".into(), 3), ("git".into(), 2)]; let result = filter_by_min_frequency(commands, 3); assert_eq!(result.len(), 1); assert_eq!(result[0].0, "ls"); @@ -159,4 +157,4 @@ mod tests { let result = filter_by_min_frequency(commands, 1); assert!(result.is_empty()); } -} \ No newline at end of file +} diff --git a/src/aggregators/command_ranker.rs b/src/aggregators/command_ranker.rs index fe53c34..dee395b 100644 --- a/src/aggregators/command_ranker.rs +++ b/src/aggregators/command_ranker.rs @@ -1,14 +1,21 @@ use std::collections::HashMap; -// Sort commands by frequency. Descending count, alphabetical tie-break. -// Takes the raw counter output and turns it into a ranked list. +/// Sort commands by frequency descending, with alphabetical tie-breaking. +/// +/// ```rust +/// let mut map = std::collections::HashMap::new(); +/// map.insert("git".into(), 3); +/// map.insert("ls".into(), 5); +/// map.insert("cd".into(), 1); +/// let ranked = shellist::rank_commands(map); +/// assert_eq!(ranked[0], ("ls".to_string(), 5)); +/// assert_eq!(ranked[1], ("git".to_string(), 3)); +/// assert_eq!(ranked[2], ("cd".to_string(), 1)); +/// ``` pub fn rank_commands(map: HashMap) -> Vec<(String, usize)> { let mut ranked: Vec<(String, usize)> = map.into_iter().collect(); - ranked.sort_by(|a, b| { - b.1.cmp(&a.1) - .then_with(|| a.0.cmp(&b.0)) - }); + ranked.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0))); ranked } diff --git a/src/aggregators/mod.rs b/src/aggregators/mod.rs index cb21e74..d11c075 100644 --- a/src/aggregators/mod.rs +++ b/src/aggregators/mod.rs @@ -1,4 +1,4 @@ -// Aggregation shit. Counts, groups, tallies, filters commands. +//! Command aggregation: counting, ranking, and filtering. pub mod command_counter; pub mod command_filter; diff --git a/src/io/file_loader.rs b/src/io/file_loader.rs index 93747f2..4bdfc54 100644 --- a/src/io/file_loader.rs +++ b/src/io/file_loader.rs @@ -1,18 +1,28 @@ -use std::fs; use std::io; use std::path::PathBuf; -// Read a history file and return its contents as a string. +/// Read a history file and return its contents. +/// +/// ```rust,no_run +/// let content = shellist::load_history_file("/home/user/.bash_history").unwrap(); +/// ``` pub fn load_history_file(path: &str) -> io::Result { - fs::read_to_string(path) + std::fs::read_to_string(path) } -// Resolve ~/.bash_history. Panics if HOME isn't set (it always is). +/// Resolve the default history file path (`~/.bash_history`). +/// +/// Uses the `HOME` environment variable. +/// +/// ```rust +/// let path = shellist::default_history_path(); +/// assert!(path.is_absolute()); +/// assert_eq!(path.file_name().unwrap(), ".bash_history"); +/// ``` pub fn default_history_path() -> PathBuf { dirs_or_home().join(".bash_history") } -// Try the dirs crate convention first, fall back to HOME env var. fn dirs_or_home() -> PathBuf { std::env::var_os("HOME") .map(PathBuf::from) diff --git a/src/io/mod.rs b/src/io/mod.rs index b787c53..8188e0c 100644 --- a/src/io/mod.rs +++ b/src/io/mod.rs @@ -1,4 +1,4 @@ -// File I/O for loading history files from disk. +//! File I/O for loading history files from disk. pub mod file_loader; diff --git a/src/lib.rs b/src/lib.rs index f8ff491..1adb191 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,16 +1,35 @@ -// Shell history analysis lib. Parses, counts, ranks commands. +//! Shell history analysis. Parses `.bash_history`, counts commands, and ranks by frequency. +//! +//! ```rust +//! let ranked = shellist::analyze("ls\ngit\nls\ncd\ngit\nls"); +//! assert_eq!(ranked, vec![ +//! ("ls".to_string(), 3), +//! ("git".to_string(), 2), +//! ("cd".to_string(), 1), +//! ]); +//! ``` pub mod aggregators; pub mod io; pub mod models; pub mod parsers; -pub use aggregators::{count_commands, filter_by_min_frequency, filter_commands, rank_commands, top_n}; +pub use aggregators::{ + count_commands, filter_by_min_frequency, filter_commands, rank_commands, top_n, +}; pub use io::{default_history_path, load_history_file}; pub use models::HistoryEntry; -pub use parsers::{parse_history, DefaultHistoryParser, HistoryParser}; +pub use parsers::{DefaultHistoryParser, HistoryParser, parse_history}; -// Full pipeline: raw history text in, ranked commands out. +/// Full pipeline: raw history text in, ranked commands out. +/// +/// Parses input into entries, counts each command (lowercased), then ranks by frequency +/// with alphabetical tie-breaking. +/// +/// ```rust +/// let result = shellist::analyze("git push\ngit commit\ncargo test\ncd ..\ncd /\n"); +/// assert_eq!(result.first(), Some(&("cd".to_string(), 2))); +/// ``` pub fn analyze(input: &str) -> Vec<(String, usize)> { let entries = parse_history(input); let counts = count_commands(&entries); diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..fa0f011 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,192 @@ +//! Shellist — shell history analysis CLI. +//! +//! Reads `.bash_history`, ranks commands by frequency, prints a table. +//! +//! ```text +//! $ shellist +//! Rank Command Count +//! 1 ls 120 +//! 2 git 95 +//! 3 cd 80 +//! ``` +//! +//! Options: +//! - `--top N` Show only the top N commands +//! - `--ignore X,Y` Exclude commands (comma-separated) +//! - `--min N` Only commands with count >= N +//! - `--path P` Read from `P` instead of `~/.bash_history` +//! - `--help` Print this + +use std::env; +use std::process; + +use shellist::{ + analyze, default_history_path, filter_by_min_frequency, filter_commands, load_history_file, + top_n, +}; + +struct Args { + top: Option, + ignore: Vec, + min_freq: Option, + path: Option, +} + +fn parse_args() -> Args { + let mut args = Args { + top: None, + ignore: Vec::new(), + min_freq: None, + path: None, + }; + + let mut iter = env::args().skip(1); + while let Some(arg) = iter.next() { + match arg.as_str() { + "--help" => { + print_help(); + process::exit(0); + } + "--top" => { + let val = iter.next().unwrap_or_else(|| { + eprintln!("shellist: --top requires a number"); + process::exit(1); + }); + args.top = Some(val.parse().unwrap_or_else(|_| { + eprintln!("shellist: --top expects a number, got '{val}'"); + process::exit(1); + })); + } + "--ignore" => { + let val = iter.next().unwrap_or_else(|| { + eprintln!("shellist: --ignore requires a comma-separated list"); + process::exit(1); + }); + args.ignore = val.split(',').map(|s| s.trim().to_string()).collect(); + } + "--min" => { + let val = iter.next().unwrap_or_else(|| { + eprintln!("shellist: --min requires a number"); + process::exit(1); + }); + args.min_freq = Some(val.parse().unwrap_or_else(|_| { + eprintln!("shellist: --min expects a number, got '{val}'"); + process::exit(1); + })); + } + "--path" => { + let val = iter.next().unwrap_or_else(|| { + eprintln!("shellist: --path requires a file path"); + process::exit(1); + }); + args.path = Some(val); + } + other => { + eprintln!("shellist: unknown flag '{other}'"); + process::exit(1); + } + } + } + + args +} + +fn print_help() { + eprintln!( + "shellist — shell history analysis + +USAGE: + shellist [OPTIONS] + +OPTIONS: + --top N Show only the top N commands + --ignore X,Y Exclude commands (comma-separated) + --min N Only commands used at least N times + --path PATH Read from PATH instead of ~/.bash_history + --help Print this help" + ); +} + +fn print_table(commands: &[(String, usize)]) { + if commands.is_empty() { + return; + } + + let rank_width = commands.len().to_string().len().max(4); + let cmd_width = commands + .iter() + .map(|(c, _)| c.len()) + .max() + .unwrap_or(7) + .max(7); + let count_width = commands + .iter() + .map(|(_, n)| n.to_string().len()) + .max() + .unwrap_or(5) + .max(5); + + println!( + "{:>rw$} {:nw$}", + "Rank", + "Command", + "Count", + rw = rank_width, + cw = cmd_width, + nw = count_width + ); + println!( + "{:-rw$} {:nw$}", + i + 1, + cmd, + count, + rw = rank_width, + cw = cmd_width, + nw = count_width + ); + } +} + +fn run() -> Result<(), Box> { + let args = parse_args(); + + let content = if let Some(ref path) = args.path { + load_history_file(path)? + } else { + let path = default_history_path(); + load_history_file(path.to_str().unwrap())? + }; + + let mut ranked = analyze(&content); + + if !args.ignore.is_empty() { + ranked = filter_commands(ranked, &args.ignore); + } + if let Some(min) = args.min_freq { + ranked = filter_by_min_frequency(ranked, min); + } + if let Some(n) = args.top { + ranked = top_n(ranked, n); + } + + print_table(&ranked); + Ok(()) +} + +fn main() { + if let Err(e) = run() { + eprintln!("shellist: {e}"); + process::exit(1); + } +} diff --git a/src/models/history_entry.rs b/src/models/history_entry.rs index f90503c..9cad92a 100644 --- a/src/models/history_entry.rs +++ b/src/models/history_entry.rs @@ -1,12 +1,17 @@ -// One parsed line from bash history. +/// One parsed line from bash history. +/// +/// Holds the raw input line and the extracted base command. +/// The command is the first whitespace-delimited token (`git` from `git push -f`). #[derive(Debug, Clone, PartialEq, Eq)] pub struct HistoryEntry { + /// The full raw line exactly as it appeared in the history file. pub raw: String, + /// The extracted base command (first token, no args). pub command: String, } impl HistoryEntry { - // Build it. That's it. + /// Create a new entry from a raw line and its extracted command. pub fn new(raw: impl Into, command: impl Into) -> Self { Self { raw: raw.into(), diff --git a/src/models/mod.rs b/src/models/mod.rs index 8504901..11cda63 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -1,4 +1,4 @@ -// Domain models for shell history shit. +//! Domain types for shell history entries. pub mod history_entry; diff --git a/src/parsers/default_history_parser.rs b/src/parsers/default_history_parser.rs index 375f097..f20026c 100644 --- a/src/parsers/default_history_parser.rs +++ b/src/parsers/default_history_parser.rs @@ -1,12 +1,21 @@ use crate::models::HistoryEntry; use crate::parsers::HistoryParser; -// Default parser. Handles normal bash history files. -// Strips whitespace, skips empties, grabs first token as command. +/// Default parser for bash history files. +/// +/// Strips whitespace, skips empty lines, grabs the first token as the command. +/// ```rust +/// use shellist::{DefaultHistoryParser, HistoryParser}; +/// let entries = DefaultHistoryParser::new().parse("ls -la\n\n git push "); +/// assert_eq!(entries.len(), 2); +/// assert_eq!(entries[0].command, "ls"); +/// assert_eq!(entries[1].command, "git"); +/// ``` #[derive(Debug, Default, Clone, Copy)] pub struct DefaultHistoryParser; impl DefaultHistoryParser { + /// Create a new parser instance. pub const fn new() -> Self { Self } @@ -19,18 +28,19 @@ impl HistoryParser for DefaultHistoryParser { .map(str::trim) .filter(|line| !line.is_empty()) .map(|line| { - let command = line - .split_whitespace() - .next() - .unwrap_or("") - .to_string(); + let command = line.split_whitespace().next().unwrap_or("").to_string(); HistoryEntry::new(line, command) }) .collect() } } -// Thin wrapper so you don't have to instantiate the parser every damn time. +/// Convenience function: parse history using [`DefaultHistoryParser`]. +/// +/// ```rust +/// let entries = shellist::parse_history("echo hello\ncargo build"); +/// assert_eq!(entries.len(), 2); +/// ``` pub fn parse_history(input: &str) -> Vec { DefaultHistoryParser::new().parse(input) } diff --git a/src/parsers/mod.rs b/src/parsers/mod.rs index 3109bcb..896ecee 100644 --- a/src/parsers/mod.rs +++ b/src/parsers/mod.rs @@ -1,13 +1,13 @@ -// Parsers turn raw history text into actual structs. +//! Parsers turn raw history text into structured entries. pub mod default_history_parser; -pub use default_history_parser::{parse_history, DefaultHistoryParser}; +pub use default_history_parser::{DefaultHistoryParser, parse_history}; use crate::models::HistoryEntry; -// Trait so you can swap parsers without touching caller code. -// Want zsh format? Implement this. Done. +/// Swappable parser trait. Implement this for zsh, fish, etc. pub trait HistoryParser { + /// Parse raw history text into a list of [`HistoryEntry`]s. fn parse(&self, input: &str) -> Vec; } diff --git a/tests/parser_integration_tests.rs b/tests/parser_integration_tests.rs index 80aa9d7..15349ea 100644 --- a/tests/parser_integration_tests.rs +++ b/tests/parser_integration_tests.rs @@ -1,4 +1,4 @@ -use shellist::{parse_history, DefaultHistoryParser, HistoryEntry, HistoryParser}; +use shellist::{DefaultHistoryParser, HistoryEntry, HistoryParser, parse_history}; #[test] fn integration_parse_full_pipeline() {