Repository: cantino/mcfly Branch: master Commit: b6c4d46a826c Files: 50 Total size: 262.0 KB Directory structure: gitextract_5xr7nxho/ ├── .github/ │ └── workflows/ │ ├── clippy.yml │ ├── mean_bean_ci.yml │ ├── mean_bean_deploy.yml │ └── rustfmt.yml ├── .gitignore ├── .travis.yml ├── CHANGELOG.txt ├── Cargo.toml ├── LICENSE ├── README.md ├── ci/ │ ├── before_deploy.sh │ ├── build.bash │ ├── common.bash │ ├── install.sh │ ├── script.sh │ ├── set_rust_version.bash │ └── test.bash ├── dev.bash ├── dev.fish ├── dev.zsh ├── mcfly.bash ├── mcfly.fish ├── mcfly.ps1 ├── mcfly.zsh ├── pkg/ │ └── brew/ │ └── mcfly.rb └── src/ ├── cli.rs ├── command_input.rs ├── dumper.rs ├── fake_typer.rs ├── fixed_length_grapheme_string.rs ├── history/ │ ├── db_extensions.rs │ ├── history.rs │ ├── mod.rs │ └── schema.rs ├── history_cleaner.rs ├── init.rs ├── interface.rs ├── lib.rs ├── main.rs ├── network.rs ├── node.rs ├── path_update_helpers.rs ├── settings.rs ├── shell_history.rs ├── simplified_command.rs ├── stats_generator.rs ├── time.rs ├── trainer.rs ├── training_cache.rs └── training_sample_generator.rs ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/workflows/clippy.yml ================================================ name: clippy on: [push, pull_request] jobs: clippy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v1 - uses: actions-rs/toolchain@v1 with: toolchain: stable components: clippy override: true - uses: actions-rs/clippy-check@v1 with: token: ${{ secrets.GITHUB_TOKEN }} - run: cargo clippy -- -D warnings ================================================ FILE: .github/workflows/mean_bean_ci.yml ================================================ name: Mean Bean CI on: [push, pull_request] jobs: install-cross: runs-on: ubuntu-latest steps: - uses: actions/checkout@v1 with: fetch-depth: 50 - uses: XAMPPRocky/get-github-release@v1 id: cross with: owner: rust-embedded repo: cross matches: ${{ matrix.platform }} token: ${{ secrets.GITHUB_TOKEN }} - uses: actions/upload-artifact@v4 with: name: cross-${{ matrix.platform }} path: ${{ steps.cross.outputs.install_path }} strategy: matrix: platform: [linux-musl] macos: runs-on: macos-latest strategy: fail-fast: true matrix: channel: [stable, beta] #, nightly] target: - x86_64-apple-darwin ### Disable running tests on M1 target, not currently working ### https://github.com/rust-lang/rust/issues/73908 # - aarch64-apple-darwin steps: - name: Setup | Checkout uses: actions/checkout@v2 - name: Setup | Rust uses: actions-rs/toolchain@v1 with: toolchain: stable override: true profile: minimal target: ${{ matrix.target }} - run: ci/set_rust_version.bash ${{ matrix.channel }} ${{ matrix.target }} - name: Test uses: actions-rs/cargo@v1 with: command: test args: --target ${{ matrix.target }} use-cross: false linux: runs-on: ubuntu-latest needs: install-cross steps: - uses: actions/checkout@v2 with: fetch-depth: 50 - name: Download Cross uses: actions/download-artifact@v4 with: name: cross-linux-musl path: /tmp/ - run: chmod +x /tmp/cross - run: ci/set_rust_version.bash ${{ matrix.channel }} ${{ matrix.target }} - run: ci/build.bash /tmp/cross ${{ matrix.target }} # These targets have issues with being tested so they are disabled # by default. You can try disabling to see if they work for # your project. - run: ci/test.bash /tmp/cross ${{ matrix.target }} if: | !contains(matrix.target, 'android') && !contains(matrix.target, 'bsd') && !contains(matrix.target, 'solaris') && matrix.target != 'armv5te-unknown-linux-musleabi' && matrix.target != 'sparc64-unknown-linux-gnu' strategy: fail-fast: true matrix: channel: [stable, beta] #, nightly] target: - arm-unknown-linux-gnueabi - armv7-unknown-linux-gnueabihf - i686-unknown-linux-musl - x86_64-unknown-linux-musl - x86_64-unknown-linux-gnu - aarch64-unknown-linux-gnu - aarch64-unknown-linux-musl - arm-unknown-linux-gnueabihf ================================================ FILE: .github/workflows/mean_bean_deploy.yml ================================================ on: push: # # Sequence of patterns matched against refs/tags tags: - "v*" # Push events to matching v*, i.e. v1.0, v20.15.10 name: Mean Bean Deploy env: BIN: mcfly jobs: # This job downloads and stores `cross` as an artifact, so that it can be # redownloaded across all of the jobs. Currently this copied pasted between # `mean_bean_ci.yml` and `mean_bean_deploy.yml`. Make sure to update both places when making # changes. install-cross: runs-on: ubuntu-latest steps: - uses: actions/checkout@v1 with: fetch-depth: 50 - uses: XAMPPRocky/get-github-release@v1 id: cross with: owner: rust-embedded repo: cross matches: ${{ matrix.platform }} token: ${{ secrets.GITHUB_TOKEN }} - uses: actions/upload-artifact@v4 with: name: cross-${{ matrix.platform }} path: ${{ steps.cross.outputs.install_path }} strategy: matrix: platform: [linux-musl] macos: runs-on: macos-latest strategy: matrix: target: # macOS - x86_64-apple-darwin ## Disabling creating M1 builds, will need to wait for github to release M1 VMs # - aarch64-apple-darwin steps: - name: Get tag id: tag uses: dawidd6/action-get-tag@v1 - name: Setup | Checkout uses: actions/checkout@v2 # Cache files between builds - name: Setup | Cache Cargo uses: actions/cache@v4 with: path: | ~/.cargo/registry ~/.cargo/git key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}-${{ matrix.target }} - name: Setup | Rust uses: actions-rs/toolchain@v1 with: toolchain: stable override: true profile: minimal target: ${{ matrix.target }} - name: Build | Build uses: actions-rs/cargo@v1 with: command: build args: --release --target ${{ matrix.target }} - run: tar -czvf ${{ env.BIN }}.tar.gz --directory=target/${{ matrix.target }}/release ${{ env.BIN }} - uses: XAMPPRocky/create-release@v1.0.2 id: create_release env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: tag_name: ${{ github.ref }} release_name: ${{ github.ref }} draft: false prerelease: false - uses: actions/upload-release-asset@v1 id: upload-release-asset env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: upload_url: ${{ steps.create_release.outputs.upload_url }} asset_path: ${{ env.BIN }}.tar.gz asset_name: ${{ env.BIN }}-${{steps.tag.outputs.tag}}-${{ matrix.target }}.tar.gz asset_content_type: application/gzip linux: runs-on: ubuntu-latest needs: install-cross strategy: matrix: target: - aarch64-unknown-linux-gnu - aarch64-unknown-linux-musl - arm-unknown-linux-gnueabi - arm-unknown-linux-gnueabihf - armv7-unknown-linux-gnueabihf - i686-unknown-linux-musl - x86_64-unknown-linux-musl - x86_64-unknown-linux-gnu steps: - name: Get tag id: tag uses: dawidd6/action-get-tag@v1 - uses: actions/checkout@v2 - uses: actions/download-artifact@v4 with: name: cross-linux-musl path: /tmp/ - run: chmod +x /tmp/cross - run: ci/set_rust_version.bash stable ${{ matrix.target }} - run: ci/build.bash /tmp/cross ${{ matrix.target }} RELEASE - run: tar -czvf ${{ env.BIN }}.tar.gz --directory=target/${{ matrix.target }}/release ${{ env.BIN }} - uses: XAMPPRocky/create-release@v1.0.2 id: create_release env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: tag_name: ${{ github.ref }} release_name: ${{ github.ref }} draft: false prerelease: false - name: Upload Release Asset id: 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: ${{ env.BIN }}.tar.gz asset_name: ${{ env.BIN }}-${{steps.tag.outputs.tag}}-${{ matrix.target }}.tar.gz asset_content_type: application/gzip win: runs-on: windows-latest strategy: matrix: target: - x86_64-pc-windows-msvc steps: - name: Get tag id: tag uses: dawidd6/action-get-tag@v1 - uses: actions/checkout@v2 - run: rustup.exe default - run: rustup.exe target add ${{ matrix.target }} - name: Build Release run: cargo.exe build --target ${{ matrix.target }} --release - name: Zip Binary run: 7z a ${{ env.BIN }}.zip ./target/${{ matrix.target }}/release/${{ env.BIN }}.exe - uses: XAMPPRocky/create-release@v1.0.2 id: create_release env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: tag_name: ${{ github.ref }} release_name: ${{ github.ref }} draft: false prerelease: false - name: Upload Release Asset id: 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: ${{ env.BIN }}.zip asset_name: ${{ env.BIN }}-${{steps.tag.outputs.tag}}-${{ matrix.target }}.zip asset_content_type: application/gzip ================================================ FILE: .github/workflows/rustfmt.yml ================================================ # When pushed to master, run `cargo +nightly fmt --all` and open a PR. name: rustfmt on: [push, pull_request] jobs: rustfmt: runs-on: ubuntu-latest steps: - name: Checkout sources uses: actions/checkout@v2 - name: Install stable toolchain with rustfmt available uses: actions-rs/toolchain@v1 with: toolchain: stable override: true components: rustfmt - run: cargo fmt --check ================================================ FILE: .gitignore ================================================ .idea /target **/*.rs.bk update.sh .zsh_history .zshrc /.fish .vscode/**/* ================================================ FILE: .travis.yml ================================================ # Based on the "trust" template v0.1.2 # https://github.com/japaric/trust/tree/v0.1.2 dist: trusty language: rust services: docker sudo: required env: global: - CRATE_NAME=mcfly matrix: include: # Linux # - env: TARGET=aarch64-unknown-linux-gnu - env: TARGET=arm-unknown-linux-gnueabi - env: TARGET=armv7-unknown-linux-gnueabihf - env: TARGET=i686-unknown-linux-gnu # - env: TARGET=i686-unknown-linux-musl # - env: TARGET=mips-unknown-linux-gnu # - env: TARGET=mips64-unknown-linux-gnuabi64 # - env: TARGET=mips64el-unknown-linux-gnuabi64 # - env: TARGET=mipsel-unknown-linux-gnu # - env: TARGET=powerpc-unknown-linux-gnu # - env: TARGET=powerpc64-unknown-linux-gnu # - env: TARGET=powerpc64le-unknown-linux-gnu # - env: TARGET=s390x-unknown-linux-gnu DISABLE_TESTS=1 - env: TARGET=x86_64-unknown-linux-gnu # - env: TARGET=x86_64-unknown-linux-musl # OSX # - env: TARGET=i686-apple-darwin # os: osx - env: TARGET=x86_64-apple-darwin os: osx # *BSD # - env: TARGET=i686-unknown-freebsd DISABLE_TESTS=1 # - env: TARGET=x86_64-unknown-freebsd DISABLE_TESTS=1 # - env: TARGET=x86_64-unknown-netbsd DISABLE_TESTS=1 # Windows # - env: TARGET=x86_64-pc-windows-gnu # Bare metal # These targets don't support std and as such are likely not suitable for # most crates. # - env: TARGET=thumbv6m-none-eabi # - env: TARGET=thumbv7em-none-eabi # - env: TARGET=thumbv7em-none-eabihf # - env: TARGET=thumbv7m-none-eabi # Testing other channels - env: TARGET=x86_64-unknown-linux-gnu rust: nightly - env: TARGET=x86_64-apple-darwin os: osx rust: nightly before_install: - set -e - rustup self update install: - sh ci/install.sh - source ~/.cargo/env || true script: - bash ci/script.sh after_script: set +e before_deploy: - sh ci/before_deploy.sh deploy: # - Create a `public_repo` GitHub token. Go to: https://github.com/settings/tokens/new # - Encrypt it: `travis encrypt 0123456789012345678901234567890123456789` # - Note: if your project is on travis-ci.com instead of travis-ci.org you # need to add the `--pro` flag like: # `travis encrypt 0123456789012345678901234567890123456789 --pro` # - Paste the output down here api_key: secure: "1rJFIUde5cmrrkrjBkmSFgJ3U1jRl0lf+tm0fkxfaTbaYvM1HeTNUquWKCicFthRul+TihytnIlmeCA0YhSqA+kqJps9Qw3WV9U9kLLpw/nNM1ms1x+SmSP3TetIhNftsOBpVgoX9qS46uQoljZGouU/B3a+1CIaCG+nuQL0MiB4pXi3TrQUnFtaA696NDxZxC3IR+CmTpjgJ+Q2/1oEBUW4cbYJAs8nQe+GZGf2Ijh+gLb2Qtesimf8HG1hxhfPEFq+oPlOiR3EAnFPP4QBBgSO/qo3zp4vPei+pBd3OGuWnPn3KbTNeCD4aojtjKBQQa0VES8CRISZOBwNE4Rl2w1unf4aHmXM/H2Tzy+185a3X1bilv+S6sjJlkAmvsZzdD5H9gBCBLPW0fJpNdAjR7cSaoHU3sNKXuW7pulDselz9I6fAO5Y9avBf0NbrknM3p/Fy5qn2aIQyAt4U7rswgA3ydsgHKclwTZKm6+ub+BB90gRRCX9ugods9zTre0TVxGwgz1fiukfNM1+wNpdz4jpthHi99GF7kzb8hOyHZQo5vx/mBX8kWbvZ3oQO9VA+oBs9rZnabBnJ6Mv/liPGdlbG19dxSSFzig6pza6zkWmi9UC6Ghwh6oQ/mYfaT3+0Tlql0qh0xXG9ogHhTxiexlLlFqFomp4ER+vkjNQXnE=" file_glob: true file: $CRATE_NAME-$TRAVIS_TAG-$TARGET.* on: # Here you can pick which targets will generate binary releases # In this example, there are some targets that are tested using the stable # and nightly channels. This condition makes sure there is only one release # for such targets and that's generated using the stable channel condition: $TRAVIS_RUST_VERSION = stable tags: true provider: releases prerelease: true skip_cleanup: true cache: cargo before_cache: # Travis can't cache files that are not readable by "others" - chmod -R a+r $HOME/.cargo branches: only: # release tags - /^v\d+\.\d+\.\d+.*$/ - master notifications: email: on_success: never ================================================ FILE: CHANGELOG.txt ================================================ 0.9.4 - Dec 24, 2025 - Implement case sensitivity for inputs containing uppercase letters, highlighing only matched characters (thanks @kentakom1213) - Fish fixes (thanks @praveenperera!) - Make the result selection in dark mode easier to see (thanks @cjordan) - Upgrade Rust to 2024 edition 0.9.3 - Feb 11, 2025 - Fix PowerShell VIM key scheme duplicates keypresses (thanks @jtschuster) - Display more results by default (thanks @kentakom1213) and scrolling (thanks @kentakom1213) - Hopefully fix occasional sort panics (thanks @unexge) 0.9.2 - Aug 11, 2024 - Numerous bash fixes (thanks @akinomyoga) - Bind to both emacs and vi-insert keymaps in Bash (thanks @akinomyoga) 0.9.1 - July 10, 2024 - Added the `mcfly stats` command (thanks @nicokosi) - Prevent cancellation of Bash/Zsh initialization on mcfly initialization failure (thanks @akinomyoga) - Prevent UTF-8 entry issue from new default to avoid TIOCSTI added in 0.9.0 (thanks @akinomyoga) 0.9.0 - May 31, 2024 - Make bash no longer use TIOCSTI by default (thanks @jtschuster) 0.8.6 - May 18, 2024 - Add windows asset generation (thanks @jtschuster) - Fix init issue for fish (thanks @4t8dd) 0.8.5 - May 11, 2024 - Use C:\Users\username\AppData\Local instead of C:\Users\username\AppData\Roaming (thanks @jtschuster) - Fix Fish return codes (thanks @manfredlotz) - Allow colors to be configured in a new optional config.toml (thanks @exokernel) - Paste in emacs mode (thanks @eatradish) 0.8.4 - Dec 24, 2023 - Remove spurious print when moving files. - PowerShell improvements (thanks @Namorzyny and @YoshidaRyoko!) 0.8.3 - Dec 3, 2023 - Add support for exporting command history matching a regex or date range (thanks @TD-Sky!) - Add Windows and Powershell support (thanks @jtschuster!) - Add deprecation warning on brew tap. 0.8.1 - Jun 3, 2023 - Fix use of MCFLY_DISABLE_MENU (thanks @barklan!) - Support Fish private mode support (thanks @Aeron!) - Always set MCFLY_HISTORY in zsh to support subshells (thanks @utkarshgupta137!) - Allow linking mcfly with system-provided sqlite with sqlite-bundled feature flag (thanks @jirutka!) - And allow sourcing in zsh more than once (thanks @ahatzz11 and @deekshithanand!) - Reduce size by removing unnecessary/unused regex features (thanks @jirutka!) - Add per-directory history (thanks @rawkode!) 0.8.0 - Mar 6, 2023 - Add forward-compatibility check for database schema (thanks @bnprks!) - Add MCFLY_DISABLE_RUN_COMMAND option to disable command running (thanks @chaserhkj!) - Add customizable prompt with MCFLY_PROMPT (thanks @vedkothavade!) - Replace termion with crossterm (big change, thanks @jtschuster!) - Allow ENV variables to be set to FALSE 0.7.1 - Dec 15, 2022 - Ensure at least MCFLY_HISTFILE is set for history import when HISTFILE is missing 0.7.0 - Dec 10, 2022 - Upgraded to clap4 (thanks @TD-Sky!) - Switched back to which for command location due to issues when run at root (thanks @Efreak and @joefiorini!) - Stopped exporting HISTFILE to fix issue when using nested shells (thanks @dithpri and @AndrewKvalheim!) - Added ctrl-w to vim keybinds (thanks @copy!) - Cursor no longer jumps to top when deleting history (thanks @navazjm!) - Fixed compatibility with mktemp from uutils/coreutils (thanks @jhult!) - Skip fuzzy matches when sorting by time (thanks @navazjm!) - Fix handling of open-quote strings in fish (thanks @hivehand!) 0.6.1 - Jul 16, 2022 - Avoid return 0 to prevent re-sourcing .zshrc from erroring - Vim mode improvement (thanks @fabiogibson!) - Allow switching between rank and time-based sorting with F1 (thanks @navazjm!) - Dependency security updates 0.6.0 - Mar 22, 2022 - Allow disabling of menu (thanks @michaelnavs!) - Prevent subshells from having multiple mcfly PROMPT_COMMAND hooks in bash (thanks @nfultz!) - Errors during history import do not prevent other lines from importing (thanks @qouoq!) - Store configuration in XDG directories when ~/.mcfly does not yet exist (thanks @Awernx!) 0.5.13 - Jan 24, 2022 - Fix 'illegal byte sequence' due to incorrect TIOCSTI cast (thanks @arunpersaud!) 0.5.12 - Jan 12, 2022 - Automatically detect if Zsh extended history is used (thanks @vmax!) 0.5.11 - Dec 12, 2021 - Avoid using builtins to fix WSL bug 0.5.10 - Nov 6, 2021 - Fix zsh utf-8 history encoding (thanks @onriv!) - Support Ctrl-p and Ctrl-n in vim mode (thanks @otherJL0!) - Make MCFLY_FUZZY a tuneable int (thanks @dmfay!) - Prevent errors when running bash inside of fish (thanks @btglr!) 0.5.9 - Aug 29, 2021 - Prefer unaliased commands in bash/zsh (thanks @Mic92!) - Fix zsh source message (thanks @hlascelles!) - Prevent potentially unsafe variable substitution in paths (thanks @CreativeCactus!) 0.5.8 - Aug 1, 2021 - Option to place interface at bottom of screen (thanks @agrism) - Option to sort by recency (thanks @agrism) - Option to skip prompting on command deletion (thanks @goddade) 0.5.7 - Jun 27, 2021 - Document MCFLY_RESULTS config value - Initialize database inside a transaction for speed (thanks @SafariMonkey!) - Move to cantino/homebrew-mcfly for tap install - Show run time of commands (thanks @dmfay!) - Clean PROMPT_COMMAND before joining with a semicolon in Bash - Fix zsh interactivity test - Make ^d delete forward (thanks @rbutoi!) - Move to Github Actions for build and add install script instructions (thanks @praveenperera!) 0.5.6 - Apr 1, 2021 - Fix fish shell initialization (thanks @domoritz) - Fix fish shell escaping (thanks @scooter-dangle!) 0.5.5 - Mar 12, 2021 - Fixed a crash during init without any history - Fixed a crash during init without any history - Fixed issue when deleting all history (thanks @akinnane!) - Add MCFLY_HISTORY_LIMIT to limit history search 0.5.4 - Feb 28, 2021 - Switched to `mcfly init` pattern for shell config files (thanks @b3nj5m1n!) 0.5.3 - Jan 17, 2021 - Ensure that history is appended in Bash 4+. 0.5.2 - Dec 10, 2020 - Bash 4+ should no longer have a cluttered terminal buffer (thanks @CreativeCactus) - Vim mode now starts in insert mode (thanks @JamJar00) 0.5.1 - Dec 6, 2020 - Fuzzy searching via the MCFLY_FUZZY option from @dmfay. 0.5.0 - Aug 21, 2020 - Fish support! Thanks @tjkirch! 0.4.0 - Jun 28, 2020 - Zsh support! 0.3.6 - Dec 15, 2019 - Optional VI-style keybindings from @JamJar00 0.3.5 - Aug 30, 2019 - Remake the history file if it gets removed 0.3.4 - May 24, 2019 - Only read 256 bytes for session id generation (thanks @SuperSandro2000!) - Prevent adding empty commands - Try using unlock_notify to prevent race condition with locked DB. - Ensure stdin is a tty to fix issue with Sublime Text 3 (thanks @abuzze!) 0.3.3 - Feb 11, 2019 - Fix version number 0.3.2 - Feb 10, 2019 - Fix 'cat /dev/urandom' not closing (thanks @Melkor333!) - Update to Rust 2018 - Error gracefully when .bash_history is not found - Add more Xes for Slackware Linux (thanks @aik099) 0.3.1 - Dec 25, 2018 - Fix background color on Light Mode 0.3.0 - Dec 25, 2018 - Support users who have `set -o vi` (thanks @Asdalo21) - Remove Regex dependency for a smaller binary. - Add support for Light Mode - enable with `export MCFLY_LIGHT=TRUE` (thanks @mshron) - Fix broken Rust install link (thanks @bperel) 0.2.5 - Dec 9, 2018 - Prevent clobbering of command return statuses (thanks @gwk) - Add Ctrl-n and Ctrl-p mappings (thanks @greyblake) - Support spaces in HISTFILE (thanks @markusjevringgoeuro) 0.2.4 - Dec 4, 2018 - Important update: fixes bug where historical directory paths would be incorrectly updated when a directory that was a prefix of another was moved, resulting in historical directory references that never actually existed. - Silences logs when moving / renaming directories. - Fixes importing of shell history that contains invalid UTF8 characters. 0.2.3 - Dec 3, 2018 - Note: 0.2.3 was never built as a release or pushed to Homebrew. - Use clobbering redirects, thanks to @gwk. ================================================ FILE: Cargo.toml ================================================ [package] name = "mcfly" version = "0.9.4" authors = ["Andrew Cantino "] edition = "2024" description = "McFly replaces your default ctrl-r shell history search with an intelligent search engine that takes into account your working directory and the context of recently executed commands. McFly's suggestions are prioritized in real time with a small neural network." license = "MIT" repository = "https://github.com/cantino/mcfly" categories = ["command-line-utilities"] exclude = ["HomebrewFormula", "HomebrewFormula/*", "pkg/*", "docs/*"] [profile.release] lto = true [profile.dev] debug = true [dependencies] config = { version = "0.15", default-features = false, features = ["toml"] } chrono = "0.4" chrono-systemd-time = "0.3" csv = "1" serde_json = "1" serde = { version = "1", features = ["derive"] } humantime = "2.1" directories-next = "2.0" itertools = "0.14" rand = "0.9" path-absolutize = "3.1" regex = { version = "1", default-features = false, features = ["perf", "std"] } shellexpand = "3" unicode-segmentation = "1.11" [dependencies.rusqlite] version = "0.38" features = ["functions", "unlock_notify"] [dependencies.crossterm] version = "0.28" features = ["use-dev-tty"] [dependencies.clap] version = "4" features = ["derive"] [target.'cfg(not(windows))'.dependencies] libc = "0.2" [target.'cfg(windows)'.dependencies] autopilot = "0.4.0" [features] default = ["sqlite-bundled"] sqlite-bundled = ["rusqlite/bundled"] ================================================ FILE: LICENSE ================================================ The MIT License Copyright (c) 2018, Andrew Cantino 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 ================================================ > **Seeking co-maintainers**: > I don't have much time to maintain this project these days. If someone would like to jump in and become a co-maintainer, it would be appreciated! ![Build Status](https://github.com/cantino/mcfly/actions/workflows/mean_bean_ci.yml/badge.svg) [![](https://img.shields.io/crates/v/mcfly.svg)](https://crates.io/crates/mcfly) # McFly - fly through your shell history screenshot McFly replaces your default `ctrl-r` shell history search with an intelligent search engine that takes into account your working directory and the context of recently executed commands. McFly's suggestions are prioritized in real time with a small neural network. TL;DR: an upgraded `ctrl-r` where history results make sense for what you're working on right now. ## Features * Rebinds `ctrl-r` to bring up a full-screen reverse history search prioritized with a small neural network. * Augments your shell history to track command exit status, timestamp, and execution directory in a SQLite database. * Maintains your normal shell history file as well so that you can stop using McFly whenever you want. * Unicode support throughout. * Includes a simple action to scrub any history item from the McFly database and your shell history files. * Designed to be extensible for other shells in the future. * Written in Rust, so it's fast and safe. * You can type `%` to match any number of characters when searching. * Supports Zsh, Bash (version 3+), and PowerShell (version 7+) ## Prioritization The key feature of McFly is smart command prioritization powered by a small neural network that runs in real time. The goal is for the command you want to run to always be one of the top suggestions. When suggesting a command, McFly takes into consideration: * The directory where you ran the command. You're likely to run that command in the same directory in the future. * What commands you typed before the command (e.g., the command's execution context). * How often you run the command. * When you last ran the command. * If you've selected the command in McFly before. * The command's historical exit status. You probably don't want to run old failed commands. ## Installation ### Install with Homebrew (on macOS or Linux) 1. Install `mcfly`: ```bash brew install mcfly ``` 1. Add the following to the end of your `~/.bashrc`, `~/.zshrc`, or `~/.config/fish/config.fish` file: Bash: ```bash eval "$(mcfly init bash)" ``` Zsh: ```bash eval "$(mcfly init zsh)" ``` Fish: ```bash mcfly init fish | source ``` 1. Run `. ~/.bashrc` / `. ~/.zshrc` / `source ~/.config/fish/config.fish` or restart your terminal emulator. #### Uninstalling with Homebrew 1. Remove `mcfly`: ```bash brew uninstall mcfly ``` 1. Remove the lines you added to `~/.bashrc` / `~/.zshrc` / `~/.config/fish/config.fish`. ### Install with MacPorts (on macOS) 1. Update the ports tree ```bash sudo port selfupdate ``` 1. Install `mcfly`: ```bash sudo port install mcfly ``` 1. Add the following to the end of your `~/.bashrc`, `~/.zshrc`, or `~/.config/fish/config.fish` file, as appropriate: Bash: ```bash eval "$(mcfly init bash)" ``` Zsh: ```bash eval "$(mcfly init zsh)" ``` Fish: ```bash mcfly init fish | source ``` 1. Run `. ~/.bashrc` / `. ~/.zshrc` / `source ~/.config/fish/config.fish` or restart your terminal emulator. #### Uninstalling with MacPorts 1. Remove `mcfly`: ```bash sudo port uninstall mcfly ``` 1. Remove the lines you added to `~/.bashrc` / `~/.zshrc` / `~/.config/fish/config.fish`. ### Install using WinGet on Windows (we do not maintain this install script and cannot vouch for its accuracy or safety) 1. Install `mcfly`: ```shell winget install mcfly ``` 2. Add the following to the end of your `$PROFILE`: ```shell Invoke-Expression -Command $(mcfly init powershell | out string) ``` 3. Restart your terminal #### Uninstall using Winget 1. Remove `mcfly`: ```shell winget uninstall mcfly ``` 2. Remove the lines you added to `$PROFILE`. ### Installing using our install script (macOS or Linux) 1. `curl -LSfs https://raw.githubusercontent.com/cantino/mcfly/master/ci/install.sh | sh -s -- --git cantino/mcfly` (or, if the current user doesn't have permissions to edit /usr/local/bin, then use `sudo sh -s`.) 2. Add the following to the end of your `~/.bashrc`, `~/.zshrc`, or `~/.config/fish/config.fish` file, respectively: Bash: ```bash eval "$(mcfly init bash)" ``` Zsh: ```bash eval "$(mcfly init zsh)" ``` Fish: ```bash mcfly init fish | source ``` 3. Run `. ~/.bashrc` / `. ~/.zshrc` / `source ~/.config/fish/config.fish` or restart your terminal emulator. ### Installing manually from GitHub (macOS or Linux) 1. Download the [latest release from GitHub](https://github.com/cantino/mcfly/releases). 1. Install to a location in your `$PATH`. (For example, you could create a directory at `~/bin`, copy `mcfly` to this location, and add `export PATH="$PATH:$HOME/bin"` to your `.bashrc` / `.zshrc`, or run `set -Ua fish_user_paths "$HOME/bin"` for fish.) 1. Add the following to the end of your `~/.bashrc`, `~/.zshrc`, or `~/.config/fish/config.fish`, respectively: Bash: ```bash eval "$(mcfly init bash)" ``` Zsh: ```bash eval "$(mcfly init zsh)" ``` Fish: ```bash mcfly init fish | source ``` 1. Run `. ~/.bashrc` / `. ~/.zshrc` / `source ~/.config/fish/config.fish` or restart your terminal emulator. ### Install manually from source (macOS, Linux, or Windows) 1. [Install Rust 1.40 or later](https://www.rust-lang.org/tools/install) 1. Run `git clone https://github.com/cantino/mcfly` and `cd mcfly` 1. Run `cargo install --path .` 1. Ensure `~/.cargo/bin` is in your `$PATH`. 1. Add the following to the end of your `~/.bashrc`, `~/.zshrc`, `~/.config/fish/config.fish`, or powershell `$PROFILE`, respectively: Bash: ```bash eval "$(mcfly init bash)" ``` Zsh: ```bash eval "$(mcfly init zsh)" ``` Fish: ```bash mcfly init fish | source ``` Powershell Core (pwsh) ```powershell Invoke-Expression -Command $(mcfly init powershell | out-string) ``` 1. Run `. ~/.bashrc` / `. ~/.zshrc` / `source ~/.config/fish/config.fish` / `. $PROFILE` or restart your terminal emulator. ### Install by [Zinit](https://github.com/zdharma-continuum/zinit) * Add below code to your zshrc. ```zsh zinit ice lucid wait"0a" from"gh-r" as"program" atload'eval "$(mcfly init zsh)"' zinit light cantino/mcfly ``` * It will download mcfly and install for you. * `$(mcfly init zsh)` will be executed after prompt ## iTerm2 To avoid McFly's UI messing up your scrollback history in iTerm2, make sure this option is unchecked: iterm2 UI instructions ## Dump history McFly can dump the command history into *stdout*. For example: ```bash mcfly dump --since '2023-01-01' --before '2023-09-12 09:15:30' ``` will dump the command run between *2023-01-01 00:00:00.0* to *2023-09-12 09:15:30*(**exclusive**) as **json**. You can specify **csv** as dump format via `--format csv` as well. Each item in dumped commands has the following fields: * `cmd`: The run command. * `when_run`: The time when the command ran in your local timezone. You can dump all the commands history without any arguments: ```bash mcfly dump ``` ### Timestamp format McFly parses timestamps via `chrono-systemd-time`, a non-strict implementation of [systemd.time](https://www.freedesktop.org/software/systemd/man/systemd.time.html), with the following exceptions: * time units **must** accompany all time span values. * time zone suffixes are **not** supported. * weekday prefixes are **not** supported. McFly users simply need to understand **specifying timezone in timestamp isn't allowed**. McFly will always use your **local timezone**. For more details, please refer to the [`chrono-systemd-time` documentation](https://docs.rs/chrono-systemd-time/latest/chrono_systemd_time/). ### Regex *Dump* supports filtering commands with regex. The regex syntax follows [crate regex](https://docs.rs/regex/latest/regex/#syntax). For example: ```bash mcfly dump -r '^cargo run' ``` will dump all command prefixes with `cargo run`. You can use `-r/--regex` and time options at the same time. For example: ```bash mcfly dump -r '^cargo run' --since '2023-09-12 09:15:30' ``` will dump all command prefixes with `cargo run` ran since *2023-09-12 09:15:30*. ## Settings A number of settings can be set via environment variables. To set a setting you should add the following snippets to your `~/.bashrc` / `~/.zshrc` / `~/.config/fish/config.fish`. [Color settings](https://github.com/cantino/mcfly/blob/b54adb65e1567887fe430188324c09553431eb7c/src/settings.rs#L508) can be set in a config file, which will be in `~/.mcfly` if it exists, otherwise in `$XDG_DATA_DIR/mcfly`. On MacOS, this would be `~/Library/Application Support/McFly/config.toml`. ### Light Mode To swap the color scheme for use in a light terminal, set the environment variable `MCFLY_LIGHT`. bash / zsh: ```bash export MCFLY_LIGHT=TRUE ``` fish: ```bash set -gx MCFLY_LIGHT TRUE ``` powershell: ```powershell $env:MCFLY_LIGHT = "TRUE" ``` Tip: on macOS you can use the following snippet for color scheme to be configured based on system-wide settings: bash / zsh: ```bash if [[ "$(defaults read -g AppleInterfaceStyle 2&>/dev/null)" != "Dark" ]]; then export MCFLY_LIGHT=TRUE fi ``` ### VIM Key Scheme By default Mcfly uses an `emacs` inspired key scheme. If you would like to switch to the `vim` inspired key scheme, set the environment variable `MCFLY_KEY_SCHEME`. bash / zsh: ```bash export MCFLY_KEY_SCHEME=vim ``` fish: ```bash set -gx MCFLY_KEY_SCHEME vim ``` powershell: ```powershell $env:MCFLY_KEY_SCHEME="vim" ``` ### Fuzzy Searching To enable fuzzy searching, set `MCFLY_FUZZY` to an integer. 0 is off; higher numbers weight toward shorter matches. Values in the 2-5 range get good results so far; try a few and [report what works best for you](https://github.com/cantino/mcfly/issues/183)! bash / zsh: ```bash export MCFLY_FUZZY=2 ``` fish: ```bash set -gx MCFLY_FUZZY 2 ``` powershell: ```powershell $env:MCFLY_FUZZY=2 ``` ### Results Count To change the maximum number of results shown, set `MCFLY_RESULTS` (default: 30). bash / zsh: ```bash export MCFLY_RESULTS=50 ``` fish: ```bash set -gx MCFLY_RESULTS 50 ``` powershell: ```powershell $env:MCFLY_RESULTS=50 ``` ### Delete without confirmation To delete without confirmation, set `MCFLY_DELETE_WITHOUT_CONFIRM` to true. bash / zsh: ```bash export MCFLY_DELETE_WITHOUT_CONFIRM=true ``` fish: ```bash set -gx MCFLY_DELETE_WITHOUT_CONFIRM true ``` powershell: ```powershell $env:MCFLY_DELETE_WITHOUT_CONFIRM="true" ``` ### Interface view To change interface view, set `MCFLY_INTERFACE_VIEW` (default: `TOP`). Available options: `TOP` and `BOTTOM` bash / zsh: ```bash export MCFLY_INTERFACE_VIEW=BOTTOM ``` fish: ```bash set -gx MCFLY_INTERFACE_VIEW BOTTOM ``` powershell: ```powershell $env:MCFLY_INTERFACE_VIEW="BOTTOM" ``` ### Disable menu interface To disable the menu interface, set the environment variable `MCFLY_DISABLE_MENU`. bash / zsh: ```bash export MCFLY_DISABLE_MENU=TRUE ``` fish: ```bash set -gx MCFLY_DISABLE_MENU TRUE ``` powershell: ```powershell $env:MCFLY_DISABLE_MENU=true ``` ### Results sorting To change the sorting of results shown, set `MCFLY_RESULTS_SORT` (default: RANK). Possible values `RANK` and `LAST_RUN` bash / zsh: ```bash export MCFLY_RESULTS_SORT=LAST_RUN ``` fish: ```bash set -gx MCFLY_RESULTS_SORT LAST_RUN ``` powershell: ```powershell $env:MCFLY_RESULTS_SORT="LAST_RUN" ``` ### Custom Prompt To change the prompt, set `MCFLY_PROMPT` (default: `$`). bash / zsh: ```bash export MCFLY_PROMPT="❯" ``` fish: ```bash set -gx MCFLY_PROMPT "❯" ``` powershell: ```powershell $env:MCFLY_PROMPT=">" ``` Note that only single-character-prompts are allowed. setting `MCFLY_PROMPT` to `""` will reset it to the default prompt. ### Database Location McFly stores its SQLite database in the standard location for the OS. On OS X, this is in `~/Library/Application Support/McFly`, on Linux it is in `$XDG_DATA_DIR/mcfly/history.db` (default would be `~/.local/share/mcfly/history.db`), and on Windows, it is `%LOCALAPPDATA%\McFly\data\history.db`. For legacy support, if `~/.mcfly/` exists, it is used instead. ### Slow startup If you have a very large history database and you notice that McFly launches slowly, you can set `MCFLY_HISTORY_LIMIT` to something like 10000 to limit how many records are considered when searching. In this example, McFly would search only the latest 10,000 entries. ### Bash TIOCSTI Starting with Linux kernel version 6.2, some systems have disabled TIOCSTI (which McFly previously used to write the selected command). McFly works around this issue by using two "dummy" keybindings, which default to `ctrl-x 1` and `ctrl-x 2`. If you are using either of these for another purpose, you can set the `MCFLY_BASH_SEARCH_KEYBINDING` and `MCFLY_BASH_ACCEPT_LINE_KEYBINDING`, respectively, to something you are not using. If you would prefer to use the legacy TIOCSTI behavior, you can enable it by setting the `sysctl` variable `dev.tty.legacy_tiocsti` to `1` on your system and set the `MCFLY_BASH_USE_TIOCSTI` bash variable to `1`. ## HISTTIMEFORMAT McFly currently doesn't parse or use `HISTTIMEFORMAT`. ## Possible Future Features * Add a screencast to README. * Learn common command options and autocomplete them in the suggestion UI? * Sort command line args when coming up with the template matching string. * Possible prioritization improvements: * Cross validation & explicit training set selection. * Learn command embeddings ## Development ### Contributing Contributions and bug fixes are encouraged! However, we may not merge PRs that increase complexity significantly beyond what is already required to maintain the project. If you're in doubt, feel free to open an issue and ask. ### Running tests `cargo test` ### Releasing (notes for @cantino) 1. Edit `Cargo.toml` and bump the version. 1. Edit CHANGELOG.txt 1. Run `cargo clippy` and `cargo fmt`. 1. Recompile (`cargo build`) and test (`cargo test`) 1. `git add -p` 1. `git ci -m 'Bumping to vx.x.x'` 1. `git tag vx.x.x` 1. `git push origin head --tags` 1. Let the build finish. 1. Edit the new Release on Github. 1. `cargo publish` 1. TBD: update homebrew-core Formula at https://github.com/Homebrew/homebrew-core/blob/master/Formula/m/mcfly.rb Old: 1. Edit `pkg/brew/mcfly.rb` and update the version and SHAs. (`shasum -a 256 ...`) 1. Edit `../homebrew-mcfly/pkg/brew/mcfly.rb` too. 1. `cp pkg/brew/mcfly.rb ../homebrew-mcfly/pkg/brew/mcfly.rb` 1. Compare with `diff ../homebrew-mcfly/pkg/brew/mcfly.rb ../mcfly/pkg/brew/mcfly.rb ; diff ../homebrew-mcfly/HomebrewFormula/mcfly.rb ../mcfly/HomebrewFormula/mcfly.rb` 1. `git add -p && git ci -m 'Update homebrew' && git push` 1. `cd ../homebrew-mcfly && git add -p && git ci -m 'Update homebrew' && git push && cd ../mcfly` ================================================ FILE: ci/before_deploy.sh ================================================ # This script takes care of building your crate and packaging it for release set -ex main() { local src=$(pwd) \ stage= case $TRAVIS_OS_NAME in linux) stage=$(mktemp -d) ;; osx) stage=$(mktemp -d -t tmp) ;; esac test -f Cargo.lock || cargo generate-lockfile cross rustc --bin mcfly --target $TARGET --release -- -C lto # strip target/$TARGET/release/mcfly cp target/$TARGET/release/mcfly $stage/ cp mcfly.bash $stage/ cp mcfly.zsh $stage/ cp mcfly.fish $stage/ cd $stage tar czf $src/$CRATE_NAME-$TRAVIS_TAG-$TARGET.tar.gz * cd $src rm -rf $stage } main ================================================ FILE: ci/build.bash ================================================ #!/usr/bin/env bash # Script for building your rust projects. set -e source ci/common.bash # $1 {path} = Path to cross/cargo executable CROSS=$1 # $1 {string} = e.g. x86_64-pc-windows-msvc TARGET_TRIPLE=$2 # $3 {boolean} = Are we building for deployment? RELEASE_BUILD=$3 required_arg $CROSS 'CROSS' required_arg $TARGET_TRIPLE '' if [ -z "$RELEASE_BUILD" ]; then $CROSS build --target $TARGET_TRIPLE $CROSS build --target $TARGET_TRIPLE --all-features else $CROSS build --target $TARGET_TRIPLE --all-features --release fi ================================================ FILE: ci/common.bash ================================================ required_arg() { if [ -z "$1" ]; then echo "Required argument $2 missing" exit 1 fi } ================================================ FILE: ci/install.sh ================================================ #!/bin/sh # Heavily modified from https://github.com/japaric/trust/blob/gh-pages/install.sh. help() { cat <<'EOF' Install a binary release of a Rust crate hosted on GitHub. Usage: install.sh [options] Options: -h, --help Display this message --git SLUG Get the crate from "https://github/$SLUG" -f, --force Force overwriting an existing binary --crate NAME Name of the crate to install (default ) --tag TAG Tag (version) of the crate to install (default ) --to LOCATION Where to install the binary (default /usr/local/bin) EOF } say() { echo "install.sh: $1" } say_err() { say "$1" >&2 } err() { if [ -n "$td" ]; then rm -rf "$td" fi say_err "ERROR $1" exit 1 } need() { if ! command -v "$1" > /dev/null 2>&1; then err "need $1 (command not found)" fi } force=false while test $# -gt 0; do case $1 in --crate) crate=$2 shift ;; --force | -f) force=true ;; --git) git=$2 shift ;; --help | -h) help exit 0 ;; --tag) tag=$2 shift ;; --to) dest=$2 shift ;; *) ;; esac shift done # Dependencies need basename need curl need install need mkdir need mktemp need tar # Optional dependencies if [ -z "$crate" ] || [ -z "$tag" ] || [ -z "$target" ]; then need cut fi if [ -z "$tag" ]; then need grep fi if [ -z "$git" ]; then # shellcheck disable=SC2016 err 'must specify a git repository using `--git`. Example: `install.sh --git japaric/cross`' fi url="https://github.com/$git" if [ "$(curl --head --write-out "%{http_code}\n" --silent --output /dev/null "$url")" -eq "404" ]; then err "GitHub repository $git does not exist" fi say_err "GitHub repository: $url" if [ -z "$crate" ]; then crate=$(echo "$git" | cut -d'/' -f2) fi say_err "Crate: $crate" if [ -z "$dest" ]; then dest="/usr/local/bin" fi if [ -e "$dest/$crate" ] && [ $force = false ]; then err "$crate already exists in $dest, use --force to overwrite the existing binary" fi url="$url/releases" if [ -z "$tag" ]; then tag=$(curl "https://api.github.com/repos/$git/releases/latest" | grep "tag_name" | cut -d'"' -f4) say_err "Tag: latest ($tag)" else say_err "Tag: $tag" fi case "$(uname -s)" in "Darwin") case "$(uname -m)" in "x86_64") target="x86_64-apple-darwin" ;; "arm64") ## replace when M1 builds are working # target="aarch64-apple-darwin" target="x86_64-apple-darwin" ;; esac ;; "Linux") platform="unknown-linux-musl" target="$(uname -m)-$platform" ;; esac say_err "Target: $target" url="$url/download/$tag/$crate-$tag-$target.tar.gz" say_err "Downloading: $url" if [ "$(curl --head --write-out "%{http_code}\n" --silent --output /dev/null "$url")" -eq "404" ]; then err "$url does not exist, you will need to build $crate from source" fi td=$(mktemp -d || mktemp -d -t tmp) curl -sL "$url" | tar -C "$td" -xz say_err "Installing to: $dest" for f in "$td"/*; do [ -e "$f" ] || break # handle the case of no *.wav files test -x "$f" || continue if [ -e "$dest/$crate" ] && [ $force = false ]; then err "$crate already exists in $dest" else mkdir -p "$dest" cp "$f" "$dest" chmod 0755 "$dest/$crate" fi done rm -rf "$td" ================================================ FILE: ci/script.sh ================================================ # This script takes care of testing your crate set -ex main() { cross build --target $TARGET cross build --target $TARGET --release if [ ! -z $DISABLE_TESTS ]; then return fi cross test --target $TARGET cross test --target $TARGET --release # cross run --target $TARGET # cross run --target $TARGET --release } # we don't run the "test phase" when doing deploys if [ -z $TRAVIS_TAG ]; then main fi ================================================ FILE: ci/set_rust_version.bash ================================================ #!/usr/bin/env bash set -e rustup default $1 rustup target add $2 ================================================ FILE: ci/test.bash ================================================ #!/usr/bin/env bash # Script for building your rust projects. set -e source ci/common.bash # $1 {path} = Path to cross/cargo executable CROSS=$1 # $1 {string} = TARGET_TRIPLE=$2 required_arg $CROSS 'CROSS' required_arg $TARGET_TRIPLE '' $CROSS test --target $TARGET_TRIPLE $CROSS test --target $TARGET_TRIPLE --all-features ================================================ FILE: dev.bash ================================================ #!/bin/bash # Build mcfly and run a dev environment bash for local mcfly testing if ! this_dir=$(cd "$(dirname "$0")" && pwd); then exit $? fi rm -f target/debug/mcfly cargo build # For some reason, to get line numbers in backtraces, we have to run the binary directly. HISTFILE=$HOME/.bash_history \ MCFLY_PATH=target/debug/mcfly \ RUST_BACKTRACE=full \ MCFLY_DEBUG=1 \ PATH=target/debug/:$PATH \ exec /bin/bash --init-file "$this_dir/mcfly.bash" -i ================================================ FILE: dev.fish ================================================ #!/bin/bash # Build mcfly and run a dev environment fish for local mcfly testing this_dir=$(cd `dirname "$0"`; pwd) # Setup for local testing. mkdir -p $this_dir/.fish rm -f target/debug/mcfly cargo build # For some reason, to get line numbers in backtraces, we have to run the binary directly. XDG_DATA_HOME=$this_dir/.fish \ MCFLY_PATH=target/debug/mcfly \ RUST_BACKTRACE=full \ MCFLY_DEBUG=1 \ PATH=target/debug/:$PATH \ exec /usr/bin/env fish -i --init-command "source $this_dir/mcfly.fish; and mcfly_key_bindings" ================================================ FILE: dev.zsh ================================================ #!/bin/bash # Build mcfly and run a dev environment zsh for local mcfly testing this_dir=$(cd `dirname "$0"`; pwd) # Setup for local testing. touch $this_dir/.zsh_history # Needed so that the test instance of zsh sources the local mcfly.zsh file on startup. echo "source ./mcfly.zsh" > $this_dir/.zshrc rm -f target/debug/mcfly cargo build # For some reason, to get line numbers in backtraces, we have to run the binary directly. HISTFILE=$HOME/.zsh_history \ MCFLY_PATH=target/debug/mcfly \ RUST_BACKTRACE=full \ MCFLY_DEBUG=1 \ ZDOTDIR="$this_dir" \ PATH=target/debug/:$PATH \ /bin/zsh -i ================================================ FILE: mcfly.bash ================================================ #!/bin/bash function mcfly_initialize { # Note: We avoid using [[ ... ]] to check the Bash version because we are # even unsure whether it is available before confirming the Bash version. if [ -z "${BASH_VERSINFO-}" ] || [ "${BASH_VERSINFO-}" -lt 3 ]; then printf 'mcfly.bash: This setup requires Bash >= 3.0.' >&2 return 1 fi unset -f "${FUNCNAME[0]}" # Ensure stdin is a tty [[ -t 0 ]] || return 0 # Ensure an interactive shell [[ $- =~ .*i.* ]] || return 0 # Avoid loading this file more than once [[ ${__MCFLY_LOADED-} != "loaded" ]] || return 0 __MCFLY_LOADED="loaded" # Setup MCFLY_HISTFILE and make sure it exists. export MCFLY_HISTFILE="${HISTFILE:-$HOME/.bash_history}" MCFLY_BASH_SEARCH_KEYBINDING=${MCFLY_BASH_SEARCH_KEYBINDING:-"\C-x1"} MCFLY_BASH_ACCEPT_LINE_KEYBINDING=${MCFLY_BASH_ACCEPT_LINE_KEYBINDING:-"\C-x2"} if [[ ! -r ${MCFLY_HISTFILE} ]]; then echo "McFly: ${MCFLY_HISTFILE} does not exist or is not readable. Please fix this or set HISTFILE to something else before using McFly." return 1 fi # MCFLY_SESSION_ID is used by McFly internally to keep track of the commands from a particular terminal session. MCFLY_SESSION_ID="$(command dd if=/dev/urandom bs=256 count=1 2> /dev/null | LC_ALL=C command tr -dc 'a-zA-Z0-9' | command head -c 24)" export MCFLY_SESSION_ID # Find the binary MCFLY_PATH=${MCFLY_PATH:-$(builtin type -P mcfly)} if [[ $MCFLY_PATH != /* ]]; then # When the user include a relative path in PATH, "builtin type -P" may # produce a relative path. We convert relative paths to the absolute ones. MCFLY_PATH=$PWD/$MCFLY_PATH fi if [[ ! -x $MCFLY_PATH ]]; then echo "Cannot find the mcfly binary, please make sure that mcfly is in your path before sourcing mcfly.bash." return 1 fi # Ignore commands with a leading space export HISTCONTROL="${HISTCONTROL:-ignorespace}" # Append new history items to .bash_history shopt -s histappend # Setup a function to be used by $PROMPT_COMMAND. function mcfly_prompt_command { local exit_code=$? # Record exit status of previous command. # Populate McFly's temporary, per-session history file from recent commands in the shell's primary HISTFILE. if [[ ! -f ${MCFLY_HISTORY-} ]]; then MCFLY_HISTORY=$(command mktemp "${TMPDIR:-/tmp}"/mcfly.XXXXXXXX) export MCFLY_HISTORY command tail -n100 "${MCFLY_HISTFILE}" >| "${MCFLY_HISTORY}" fi history -a "${MCFLY_HISTORY}" # Append history to $MCFLY_HISTORY. # Run mcfly with the saved code. It will: # * append commands to $HISTFILE, (~/.bash_history by default) # for backwards compatibility and to load in new terminal sessions; # * find the text of the last command in $MCFLY_HISTORY and save it to the database. "$MCFLY_PATH" add --exit "${exit_code}" --append-to-histfile "${MCFLY_HISTFILE}" # Clear the in-memory history and reload it from $MCFLY_HISTORY # (to remove instances of '#mcfly: ' from the local session history). history -cr "${MCFLY_HISTORY}" return "${exit_code}" # Restore the original exit code by returning it. } function mcfly_add_prompt_command { local command=$1 IFS=$' \t\n' if ((BASH_VERSINFO[0] > 5 || BASH_VERSINFO[0] == 5 && BASH_VERSINFO[1] >= 1)); then # Bash 5.1 supports array PROMPT_COMMAND, where we register our prompt # command to a new element PROMPT_COMMAND[i] (with i >= 1) to avoid # conflicts with other frameworks. if [[ " ${PROMPT_COMMAND[*]-} " != *" $command "* ]]; then PROMPT_COMMAND[0]=${PROMPT_COMMAND[0]:-} # Note: We here use eval to avoid syntax error in Bash < 3.1. We drop # the support for Bash < 3.0, but this is still needed to avoid parse # error before the Bash version check is performed. eval 'PROMPT_COMMAND+=("$command")' fi elif [[ -z ${PROMPT_COMMAND-} ]]; then PROMPT_COMMAND="$command" elif [[ $PROMPT_COMMAND != *"mcfly_prompt_command"* ]]; then PROMPT_COMMAND="$command;${PROMPT_COMMAND#;}" fi } # Set $PROMPT_COMMAND run mcfly_prompt_command, preserving any existing $PROMPT_COMMAND. mcfly_add_prompt_command "mcfly_prompt_command" function mcfly_search_with_tiocsti { local LAST_EXIT_CODE=$? IFS=$' \t\n' echo "#mcfly: ${READLINE_LINE[*]}" >> "$MCFLY_HISTORY" READLINE_LINE= "$MCFLY_PATH" search return "$LAST_EXIT_CODE" } # Runs mcfly search with output to file, reads the output, and sets READLINE_LINE to the command. # If the command is to be run, binds the MCFLY_KEYSTROKE2 to accept-line, otherwise binds it to nothing. function mcfly_search { local LAST_EXIT_CODE=$? IFS=$' \t\n' # Get a temp file name but don't create the file - mcfly will create the file for us. local MCFLY_OUTPUT MCFLY_OUTPUT=$(command mktemp --dry-run "${TMPDIR:-/tmp}"/mcfly.output.XXXXXXXX) echo "#mcfly: ${READLINE_LINE[*]}" >> "$MCFLY_HISTORY" "$MCFLY_PATH" search -o "$MCFLY_OUTPUT" # If the file doesn't exist, nothing was selected from mcfly, exit without binding accept-line if [[ ! -f $MCFLY_OUTPUT ]]; then bind "\"$MCFLY_BASH_ACCEPT_LINE_KEYBINDING\":\"\"" return fi # Get the command and set the bash text to it, and move the cursor to the end of the line. local MCFLY_COMMAND MCFLY_COMMAND=$(command awk 'NR==2{$1=a; print substr($0, 2)}' "$MCFLY_OUTPUT") READLINE_LINE=$MCFLY_COMMAND READLINE_POINT=${#READLINE_LINE} # Get the mode and bind the accept-line key if the mode is run. local MCFLY_MODE MCFLY_MODE=$(command awk 'NR==1{$1=a; print substr($0, 2)}' "$MCFLY_OUTPUT") if [[ $MCFLY_MODE == "run" ]]; then bind "\"$MCFLY_BASH_ACCEPT_LINE_KEYBINDING\":accept-line" else bind "\"$MCFLY_BASH_ACCEPT_LINE_KEYBINDING\":\"\"" fi command rm -f "$MCFLY_OUTPUT" return "$LAST_EXIT_CODE" } # Take ownership of ctrl-r. if ((BASH_VERSINFO[0] >= 4)); then # shellcheck disable=SC2016 if [[ ${MCFLY_BASH_USE_TIOCSTI-} == 1 ]]; then bind -m emacs -x '"\C-r": "mcfly_search_with_tiocsti"' bind -m vi-insert -x '"\C-r": "mcfly_search_with_tiocsti"' else # Bind ctrl+r to 2 keystrokes, the first one is used to search in McFly, the second one is used to run the command (if mcfly_search binds it to accept-line). bind -m emacs -x "\"$MCFLY_BASH_SEARCH_KEYBINDING\":\"mcfly_search\"" bind -m vi-insert -x "\"$MCFLY_BASH_SEARCH_KEYBINDING\":\"mcfly_search\"" bind -m emacs "\"\C-r\":\"$MCFLY_BASH_SEARCH_KEYBINDING$MCFLY_BASH_ACCEPT_LINE_KEYBINDING\"" bind -m vi-insert "\"\C-r\":\"$MCFLY_BASH_SEARCH_KEYBINDING$MCFLY_BASH_ACCEPT_LINE_KEYBINDING\"" fi else # The logic here is: # 1. Jump to the beginning of the edit buffer, add 'mcfly: ', and comment out the current line. We comment out the line # to ensure that all possible special characters, including backticks, are ignored. This commented out line will # end up as the most recent entry in the $MCFLY_HISTORY file. # 2. Type "mcfly search" and then run the command. McFly will pull the last line from the $MCFLY_HISTORY file, # which should be the commented-out search from step #1. It will then remove that line from the history file and # render the search UI pre-filled with it. bind -m emacs '"\C-r": "\C-amcfly: \e# mcfly search\C-m"' bind -m vi-insert '"\C-r": "\e0i#mcfly: \e\C-m mcfly search\C-m"' fi } mcfly_initialize ================================================ FILE: mcfly.fish ================================================ #!/usr/bin/env fish # Avoid loading this file more than once if test "$__MCFLY_LOADED" != "loaded" set -g __MCFLY_LOADED "loaded" # If this is an interactive shell if status is-interactive # Note: we only use the history file for the session when this file was sourced. # Would have to reset this before calling mcfly if you want commands from another session later. if not set -q MCFLY_HISTFILE set -gx MCFLY_HISTFILE (set -q XDG_DATA_HOME; and echo $XDG_DATA_HOME; or echo $HOME/.local/share)/fish/(set -q fish_history; and echo $fish_history; or echo fish)_history end if not test -r "$MCFLY_HISTFILE" echo "McFly: $MCFLY_HISTFILE does not exist or is not readable. Please fix this or set MCFLY_HISTFILE to something else before using McFly." >&2 exit 1 end # MCFLY_SESSION_ID is used by McFly internally to keep track of the commands from a particular terminal session. set -gx MCFLY_SESSION_ID (dd if=/dev/urandom bs=256 count=1 2>/dev/null | env LC_ALL=C tr -dc 'a-zA-Z0-9' | head -c 24) # Find the binary set -q MCFLY_PATH; or set -l MCFLY_PATH (command -v mcfly) if test -z "$MCFLY_PATH"; or test "$MCFLY_PATH" = "mcfly not found" echo "Cannot find the mcfly binary, please make sure that mcfly is in your path before sourcing mcfly.fish" exit 1 end # We don't need a MCFLY_HISTORY file because we can get the last command in fish_postexec. set -gx MCFLY_HISTORY /dev/null set -g __MCFLY_CMD $MCFLY_PATH --mcfly_history $MCFLY_HISTORY --history_format fish function __mcfly_save_old_pwd -d 'Save PWD before running command' -e fish_preexec set -g __MCFLY_OLD_PWD "$PWD" end function __mcfly_add_command -d 'Add run commands to McFly database' -e fish_postexec # Retain return code of last command before we lose it set -l last_status $status # Check for the private mode test -n "$fish_private_mode"; and return # Handle first call of this function after sourcing mcfly.fish, when the old PWD won't be set set -q __MCFLY_OLD_PWD; or set -g __MCFLY_OLD_PWD "$PWD" test -n "$MCFLY_DEBUG"; and echo mcfly.fish: Run eval $__MCFLY_CMD add --exit '$last_status' --old-dir '$__MCFLY_OLD_PWD' -- '$argv[1]' eval $__MCFLY_CMD add --exit '$last_status' --old-dir '$__MCFLY_OLD_PWD' -- '$argv[1]' end # Set up key binding functions. function __mcfly-history-widget -d "Search command history with McFly" set tmpdir $TMPDIR if test -z "$tmpdir" set tmpdir /tmp end set -l mcfly_output (mktemp "$tmpdir/mcfly.output.XXXXXXXX") eval $__MCFLY_CMD search -o '$mcfly_output' -- (commandline | string escape) # Interpret commandline/run requests from McFly set -l mode; set -l commandline while read key val test "$key" = "mode"; and set mode "$val" test "$key" = "commandline"; and set commandline "$val" test "$key" = "delete"; and history delete --exact --case-sensitive "$val" end < "$mcfly_output" rm -f $mcfly_output if test -n "$commandline" commandline "$commandline" end if test "$mode" = "run" commandline -f execute end commandline -f repaint end function mcfly_key_bindings -d "Default key bindings for McFly" bind \cr __mcfly-history-widget if bind -M insert >/dev/null 2>&1 bind -M insert \cr __mcfly-history-widget end end mcfly_key_bindings end end ================================================ FILE: mcfly.ps1 ================================================ #!/usr/bin/env pwsh $null = New-Module mcfly { # We need PSReadLine for a number of capabilities if ($null -eq (Get-Module -Name PSReadLine)) { Write-Host "Installing PSReadLine as McFly dependency" Install-Module PSReadLine } # Get history file and make a dummy file for psreadline (hopefully after it has loaded the real history file to its in memory history) $env:HISTFILE = $null -eq $env:HISTFILE -or "" -eq $env:HISTFILE ? (Get-PSReadLineOption).HistorySavePath : $env:HISTFILE; $psreadline_dummy = New-TemporaryFile # Append history to dummy file for compatibility Get-Content -Path $Env:HISTFILE | Out-File -FilePath $psreadline_dummy -Force Set-PSReadLineOption -HistorySavePath $psreadline_dummy.FullName $fileExists = Test-Path -path $env:HISTFILE if (-not $fileExists) { Write-Host "McFly: ${env:HISTFILE} does not exist or is not readable. Please fix this or set HISTFILE to something else before using McFly."; return 1; } # MCFLY_SESSION_ID is used by McFly internally to keep track of the commands from a particular terminal session. $MCFLY_SESSION_ID = new-guid $env:MCFLY_SESSION_ID = $MCFLY_SESSION_ID $env:MCFLY_HISTORY = New-TemporaryFile Get-Content $env:HISTFILE | Select-Object -Last 100 | Set-Content $env:MCFLY_HISTORY <# .SYNOPSIS Cmdlet to run McFly .PARAMETER CommandToComplete The command to complete .EXAMPLE Invoke-McFly -CommandToComplete "cargo bu" #> function Invoke-McFly { Param([string]$CommandToComplete) $lastExitTmp = $LASTEXITCODE $tempFile = New-TemporaryFile Start-Process -FilePath '::MCFLY::' -ArgumentList "search", "$CommandToComplete", -o, "$tempFile" -NoNewWindow -Wait foreach($line in Get-Content $tempFile) { $key, $value = $line -split ' ', 2 if ("mode" -eq $key) { $mode = $value } if ("commandline" -eq $key) { $commandline = $value } } if(-not ($null -eq $commandline)) { [Microsoft.PowerShell.PSConsoleReadLine]::DeleteLine() [Microsoft.PowerShell.PSConsoleReadline]::Insert($commandline) if("run" -eq $mode) { [Microsoft.PowerShell.PSConsoleReadline]::AcceptLine() } } Remove-Item $tempFile $LASTEXITCODE = $lastExitTmp } <# .SYNOPSIS Add a command to McFly's history. .PARAMETER Command The string of the command to add to McFly's history .PARAMETER ExitCode The exit code of the command to add .EXAMPLE Add-CommandToMcFly -Command "cargo build" #> function Add-CommandToMcFly { Param ( [string] $Command, [int] $ExitCode ) $ExitCode = $ExitCode ?? 0; $Command | Out-File -FilePath $env:MCFLY_HISTORY -Append Start-Process -FilePath '::MCFLY::' -ArgumentList add, --exit, $ExitCode, --append-to-histfile, $env:HISTFILE -NoNewWindow | Write-Host } # We need to make sure we call out AddToHistoryHandler right after each command is called Set-PSReadLineOption -HistorySaveStyle SaveIncrementally Set-PSReadLineOption -PredictionSource HistoryAndPlugin Set-PSReadLineOption -AddToHistoryHandler { Param([string]$Command) $lastExitTmp = $LASTEXITCODE $Command = $Command.Trim(); # PSReadLine executes this before the command even runs, so we don't know its exit code - assume 0 Add-CommandToMcFly -Command $Command -ExitCode 0 $LASTEXITCODE = $lastExitTmp # Tell PSReadLine to save the command to their in-memory history (and also the dummy file) return $true } Set-PSReadLineKeyHandler -Chord "Ctrl+r" -ScriptBlock { $line = $null $cursor = $null [Microsoft.PowerShell.PSConsoleReadline]::GetBufferState([ref]$line, [ref]$cursor) "#mcfly: $line" | Out-File -FilePath $env:MCFLY_HISTORY -Append Invoke-McFly -CommandToComplete "`"$line`"" } Export-ModuleMember -Function @( "Invoke-McFly" "Add-CommandToMcFly" ) } ================================================ FILE: mcfly.zsh ================================================ #!/bin/zsh () { # Ensure an interactive shell [[ -o interactive ]] || return 0 # Setup MCFLY_HISTFILE and make sure it exists. export MCFLY_HISTFILE="${HISTFILE:-$HOME/.zsh_history}" if [[ ! -r "${MCFLY_HISTFILE}" ]]; then echo "McFly: ${MCFLY_HISTFILE} does not exist or is not readable. Please fix this or set HISTFILE to something else before using McFly." return 1 fi # MCFLY_SESSION_ID is used by McFly internally to keep track of the commands from a particular terminal session. export MCFLY_SESSION_ID=$(command dd if=/dev/urandom bs=256 count=1 2> /dev/null | LC_ALL=C command tr -dc 'a-zA-Z0-9' | command head -c 24) # Find the binary MCFLY_PATH=${MCFLY_PATH:-$(command which mcfly)} if [[ -z "$MCFLY_PATH" || "$MCFLY_PATH" == "mcfly not found" ]]; then echo "Cannot find the mcfly binary, please make sure that mcfly is in your path before sourcing mcfly.zsh." return 1 fi # Required for commented out mcfly search commands to work. setopt interactive_comments # allow comments in interactive shells (like Bash does) # McFly's temporary, per-session history file. export MCFLY_HISTORY=$(command mktemp ${TMPDIR:-/tmp}/mcfly.XXXXXXXX) # Check if we need to use extended history if [[ -o extendedhistory ]]; then export MCFLY_HISTORY_FORMAT="zsh-extended" else export MCFLY_HISTORY_FORMAT="zsh" fi # Setup a function to be used by $PROMPT_COMMAND. function mcfly_prompt_command { local exit_code=$? # Record exit status of previous command. # Populate McFly's temporary, per-session history file from recent commands in the shell's primary HISTFILE. if [[ ! -f "${MCFLY_HISTORY}" ]]; then export MCFLY_HISTORY=$(command mktemp ${TMPDIR:-/tmp}/mcfly.XXXXXXXX) command tail -n100 "${MCFLY_HISTFILE}" >| ${MCFLY_HISTORY} fi # Write history to $MCFLY_HISTORY. fc -W "${MCFLY_HISTORY}" # Run mcfly with the saved code. It find the text of the last command in $MCFLY_HISTORY and save it to the database. [ -n "$MCFLY_DEBUG" ] && echo "mcfly.zsh: Run mcfly add --exit ${exit_code}" $MCFLY_PATH --history_format $MCFLY_HISTORY_FORMAT add --exit ${exit_code} return ${exit_code} # Restore the original exit code by returning it. } if [[ -z $precmd_functions ]] || [[ "${precmd_functions[(ie)mcfly_prompt_command]}" -gt ${#precmd_functions} ]]; then precmd_functions+=(mcfly_prompt_command) else [ -n "$MCFLY_DEBUG" ] && echo "mcfly_prompt_command already in precmd_functions, skipping" fi # Cleanup $MCFLY_HISTORY tmp files on exit. mcfly_exit_logger() { [ -n "$MCFLY_DEBUG" ] && echo "mcfly.zsh: Exiting and removing $MCFLY_HISTORY" command rm -f $MCFLY_HISTORY } if [[ -z $zshexit_functions ]] || [[ "${zshexit_functions[(ie)mcfly_exit_logger]}" -gt ${#zshexit_functions} ]]; then zshexit_functions+=(mcfly_exit_logger) else [ -n "$MCFLY_DEBUG" ] && echo "mcfly_exit_logger already in zshexit_functions, skipping" fi # Take ownership of ctrl-r. mcfly-history-widget() { () { echoti rmkx exec , /// Shell history file to read from when adding or searching (defaults to $`MCFLY_HISTORY`) #[arg(long = "mcfly_history")] pub mcfly_history: Option, /// Shell history file format #[arg( value_name = "FORMAT", value_enum, long = "history_format", default_value_t )] pub history_format: HistoryFormat, } #[derive(Subcommand)] pub enum SubCommand { /// Add commands to the history #[command(alias = "a")] Add { /// The command that was run (default last line of $`MCFLY_HISTORY` file) command: Vec, /// Exit code of command #[arg(value_name = "EXIT_CODE", short, long)] exit: Option, /// Also append command to the given file (e.q., .`bash_history`) #[arg(value_name = "HISTFILE", short, long)] append_to_histfile: Option, /// The time that the command was run (default now) #[arg(value_name = "UNIX_EPOCH", short, long)] when: Option, /// Directory where command was run (default $PWD) #[arg(value_name = "PATH", short, long = "dir")] directory: Option, /// The previous directory the user was in before running the command (default $OLDPWD) #[arg(value_name = "PATH", short, long = "old-dir")] old_directory: Option, }, /// Search the history #[command(alias = "s")] Search { /// The command search term(s) command: Vec, /// Directory where command was run (default $PWD) #[arg(value_name = "PATH", short, long = "dir")] directory: Option, /// Number of results to return #[arg(value_name = "NUMBER", short, long)] results: Option, /// Fuzzy-find results. 0 is off; higher numbers weight shorter/earlier matches more. Try 2 #[arg(short, long)] fuzzy: Option, /// Delete entry without confirm #[arg(long = "delete_without_confirm")] delete_without_confirm: bool, /// Write results to file, including selection mode, new commandline, and any shell-specific requests #[arg(value_name = "PATH", short, long)] output_selection: Option, }, /// Record a directory having been moved; moves command records from the old path to the new one Move { /// The old directory path old_dir_path: String, /// The new directory path new_dir_path: String, }, /// Train the suggestion engine (developer tool) Train { /// Directory where command was run #[arg(short, long = "refresh_cache")] refresh_cache: bool, }, /// Prints the shell code used to execute mcfly Init { /// Shell to init #[arg(value_enum)] shell: InitMode, }, /// Dump history into stdout; the results are sorted by timestamp Dump { /// Select all commands ran since the point #[arg(long)] since: Option, /// Select all commands ran before the point #[arg(long)] before: Option, /// Sort order [case ignored] #[arg( long, short, value_name = "ORDER", value_enum, default_value_t, ignore_case = true )] sort: SortOrder, /// Require commands to match the pattern #[arg(long, short)] regex: Option, /// The format to dump in #[arg(long, short, value_enum, default_value_t)] format: DumpFormat, }, /// Prints stats Stats { /// The minimum command length to be listed in the "top-n" commands #[arg( value_name = "MIN_CMD_LENGTH", short, long, value_name = "min_cmd_length", default_value_t = 0 )] min_cmd_length: i16, /// The number of "top-n" commands #[arg( value_name = "CMDS", short, long, value_name = "cmds", default_value_t = 10 )] cmds: i16, /// Break down by top directories - defaults to 0, which doesn't limit by directory #[arg( value_name = "DIRS", short, long, value_name = "dirs", default_value_t = 0 )] dirs: i16, // Skip the top n commands when breaking down by directory #[arg( value_name = "GLOBAL_CMDS_TO_IGNORE", short, long, value_name = "global_cmds_to_ignore", default_value_t = 10 )] global_commands_to_ignore: i16, /// Only show commands from a given directory #[arg(value_name = "ONLY_DIR", short, long)] only_dir: Option, }, } #[derive(Clone, Copy, ValueEnum, Default)] pub enum HistoryFormat { #[default] Bash, Zsh, ZshExtended, Fish, } #[derive(Clone, Copy, ValueEnum)] pub enum InitMode { Bash, Zsh, Fish, Powershell, } #[derive(Debug, Clone, Copy, ValueEnum, Default)] #[value(rename_all = "UPPER")] pub enum SortOrder { #[default] #[value(alias = "asc")] Asc, #[value(alias = "desc")] Desc, } #[derive(Debug, Clone, Copy, ValueEnum, Default)] pub enum DumpFormat { #[default] Json, Csv, } impl Cli { #[must_use] pub fn is_init(&self) -> bool { matches!(self.command, SubCommand::Init { .. }) } } impl SortOrder { #[inline] #[must_use] pub fn to_str(&self) -> &'static str { match self { Self::Asc => "ASC", Self::Desc => "DESC", } } } ================================================ FILE: src/command_input.rs ================================================ use std::fmt; use unicode_segmentation::UnicodeSegmentation; #[derive(Debug)] pub enum InputCommand { Insert(char), Backspace, Delete, Move(Move), } #[derive(Debug)] pub enum Move { BOL, EOL, BackwardWord, ForwardWord, Backward, Forward, Exact(usize), } #[derive(Debug)] /// `CommandInput` data structure pub struct CommandInput { /// The command itself pub command: String, /// The current cursor position pub cursor: usize, /// A cache of the length of command in graphemes (not bytes or chars!) pub len: usize, } impl fmt::Display for CommandInput { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { self.command.fmt(f) } } impl CommandInput { pub fn from>(s: S) -> CommandInput { let mut input = CommandInput { command: s.into(), cursor: 0, len: 0, }; input.recompute_caches(); input.cursor = input.len; input } pub fn clear(&mut self) { self.command.clear(); self.recompute_caches(); } pub fn set(&mut self, str: &str) { self.command = str.to_string(); self.recompute_caches(); } pub fn move_cursor(&mut self, direction: Move) { let mut tmp: isize = self.cursor as isize; match direction { Move::Backward => tmp -= 1, Move::Exact(i) => tmp = i as isize, Move::Forward => tmp += 1, Move::BOL => tmp = 0, Move::EOL => tmp = self.len as isize, Move::ForwardWord => { tmp = self.next_word_boundary() as isize; } Move::BackwardWord => { tmp = self.previous_word_boundary() as isize; } } tmp = tmp.clamp(0, self.len as isize); self.cursor = tmp as usize; } pub fn delete(&mut self, cmd: Move) { let mut new_command = String::with_capacity(self.command.len()); let command_copy = self.command.clone(); let vec = command_copy.grapheme_indices(true); match cmd { Move::Backward => { if self.cursor == 0 { return; } self.move_cursor(Move::Backward); for (count, (_, item)) in vec.enumerate() { if count != self.cursor { new_command.push_str(item); } } self.command = new_command; self.recompute_caches(); } Move::Forward => { if self.cursor == self.len { return; } for (count, (_, item)) in vec.enumerate() { if count != self.cursor { new_command.push_str(item); } } self.command = new_command; self.recompute_caches(); } Move::EOL => { if self.cursor == self.len { return; } for (count, (_, item)) in vec.enumerate() { if count < self.cursor { new_command.push_str(item); } } self.command = new_command; self.recompute_caches(); self.move_cursor(Move::EOL); } Move::BOL => { if self.cursor == 0 { return; } for (count, (_, item)) in vec.enumerate() { if count >= self.cursor { new_command.push_str(item); } } self.command = new_command; self.recompute_caches(); self.move_cursor(Move::BOL); } Move::ForwardWord => { if self.cursor == self.len { return; } let next_word_boundary = self.next_word_boundary(); for (count, (_, item)) in vec.enumerate() { if count < self.cursor || count >= next_word_boundary { new_command.push_str(item); } } self.command = new_command; self.recompute_caches(); } Move::BackwardWord => { if self.cursor == 0 { return; } let previous_word_boundary = self.previous_word_boundary(); let mut removed_characters: usize = 0; for (count, (_, item)) in vec.enumerate() { if count < previous_word_boundary || count >= self.cursor { new_command.push_str(item); } else { removed_characters += 1; } } self.command = new_command; self.recompute_caches(); let new_cursor_pos = self.cursor - removed_characters; self.move_cursor(Move::Exact(new_cursor_pos)); } _ => unreachable!(), } } pub fn insert(&mut self, c: char) { let mut new_command = String::with_capacity(self.command.len()); { let vec = self.command.graphemes(true); let mut pushed = false; for (count, item) in vec.enumerate() { if count == self.cursor { pushed = true; new_command.push(c); } new_command.push_str(item); } if !pushed { new_command.push(c); } } self.command = new_command; self.recompute_caches(); self.move_cursor(Move::Forward); } fn recompute_caches(&mut self) { self.len = self.command.graphemes(true).count(); } /// Return the index of the grapheme cluster that represents the end of the previous word before /// the cursor. fn previous_word_boundary(&self) -> usize { if self.cursor == 0 { return 0; } let mut word_boundaries = self .command .split_word_bound_indices() .map(|(i, _)| i) .collect::>(); word_boundaries.push(self.command.len().to_owned()); let mut word_index: usize = 0; let mut found_word: bool = false; let command_copy = self.command.clone(); let vec = command_copy .grapheme_indices(true) .enumerate() .collect::>(); for &(count, (offset, _)) in vec.iter().rev() { if count <= self.cursor { if !found_word && (vec[count.saturating_sub(1)].1).1 == " " { continue; // Ignore leading spaces } else if found_word { if offset == word_boundaries[word_index] { // We've found the previous word boundary. return count; } } else { found_word = true; while word_boundaries[word_index] < offset { word_index += 1; } #[allow(clippy::implicit_saturating_sub)] if word_index > 0 { word_index -= 1; } } } } 0 } /// Return the index of the grapheme cluster that represents the start of the next word after /// the cursor. fn next_word_boundary(&self) -> usize { let command_copy = self.command.clone(); let grapheme_indices = command_copy.grapheme_indices(true); let mut word_boundaries = self .command .split_word_bound_indices() .map(|(i, _)| i) .collect::>(); word_boundaries.push(self.command.len().to_owned()); let mut next_word_index: usize = 0; let mut found_word: bool = false; for (count, (offset, item)) in grapheme_indices.enumerate() { if count >= self.cursor { if !found_word && item == " " { continue; // Ignore leading spaces } else if found_word { if offset == word_boundaries[next_word_index] { // We've found the next word boundary. return count; } } else { found_word = true; while word_boundaries[next_word_index] <= offset { next_word_index += 1; } } } } self.len } } #[cfg(test)] mod tests { use super::CommandInput; #[test] fn display_works() { let input = CommandInput::from("foo bar baz"); assert_eq!(format!("{input}"), "foo bar baz"); } #[test] fn next_word_boundary_works() { let mut input = CommandInput::from("foo bar baz"); input.cursor = 0; assert_eq!(input.next_word_boundary(), 3); input.cursor = 3; assert_eq!(input.next_word_boundary(), 7); input.cursor = 4; assert_eq!(input.next_word_boundary(), 7); input.cursor = 5; assert_eq!(input.next_word_boundary(), 7); input.cursor = 6; assert_eq!(input.next_word_boundary(), 7); input.cursor = 7; assert_eq!(input.next_word_boundary(), 11); input.cursor = 11; assert_eq!(input.next_word_boundary(), 11); input.cursor = 12; assert_eq!(input.next_word_boundary(), 11); } #[test] fn previous_word_boundary_works() { let mut input = CommandInput::from("foo bar baz"); input.cursor = 0; assert_eq!(input.previous_word_boundary(), 0); input.cursor = 1; assert_eq!(input.previous_word_boundary(), 0); input.cursor = 3; assert_eq!(input.previous_word_boundary(), 0); input.cursor = 4; assert_eq!(input.previous_word_boundary(), 0); input.cursor = 5; assert_eq!(input.previous_word_boundary(), 4); input.cursor = 7; assert_eq!(input.previous_word_boundary(), 4); input.cursor = 8; assert_eq!(input.previous_word_boundary(), 4); input.cursor = 11; assert_eq!(input.previous_word_boundary(), 8); input.cursor = 12; assert_eq!(input.previous_word_boundary(), 8); } } ================================================ FILE: src/dumper.rs ================================================ use std::io::{self, BufWriter, Write}; use crate::cli::DumpFormat; use crate::history::{DumpCommand, History}; use crate::settings::Settings; use crate::time::to_datetime; #[derive(Debug)] pub struct Dumper<'a> { settings: &'a Settings, history: &'a History, } impl<'a> Dumper<'a> { #[inline] pub fn new(settings: &'a Settings, history: &'a History) -> Self { Self { settings, history } } pub fn dump(&self) { let mut commands = self .history .dump(&self.settings.time_range, &self.settings.sort_order); if commands.is_empty() { println!("McFly: No history"); return; } if let Some(pat) = &self.settings.pattern { commands.retain(|dc| pat.is_match(&dc.cmd)); } match self.settings.dump_format { DumpFormat::Json => Self::dump2json(&commands), DumpFormat::Csv => Self::dump2csv(&commands), } .unwrap_or_else(|err| panic!("McFly error: Failed while output history ({err})")); } } impl Dumper<'_> { fn dump2json(commands: &[DumpCommand]) -> io::Result<()> { let mut stdout = BufWriter::new(io::stdout().lock()); serde_json::to_writer_pretty(&mut stdout, commands).map_err(io::Error::from)?; stdout.flush() } fn dump2csv(commands: &[DumpCommand]) -> io::Result<()> { let mut wtr = csv::Writer::from_writer(io::stdout().lock()); wtr.write_record(["cmd", "when_run"])?; for dc in commands { wtr.write_record([dc.cmd.as_str(), to_datetime(dc.when_run).as_str()])?; } wtr.flush() } } ================================================ FILE: src/fake_typer.rs ================================================ #[cfg(not(windows))] use libc; // Should we be using https://docs.rs/libc/0.2.44/libc/fn.ioctl.html instead? #[cfg(not(windows))] unsafe extern "C" { pub fn ioctl(fd: libc::c_int, request: libc::c_ulong, arg: ...) -> libc::c_int; } #[cfg(not(windows))] #[allow(clippy::useless_conversion)] pub fn use_tiocsti(string: &str) { for byte in string.as_bytes() { let a: *const u8 = byte; assert!( unsafe { ioctl(0, libc::TIOCSTI.try_into().unwrap(), a) } >= 0, "Error encountered when calling ioctl" ); } } #[cfg(windows)] pub fn use_tiocsti(string: &str) { autopilot::key::type_string(string, &[], 0.0, 0.0); } ================================================ FILE: src/fixed_length_grapheme_string.rs ================================================ use std::io::Write; use unicode_segmentation::UnicodeSegmentation; #[derive(Debug)] pub struct FixedLengthGraphemeString { pub string: String, pub grapheme_length: u16, pub max_grapheme_length: u16, } impl Write for FixedLengthGraphemeString { fn write(&mut self, buf: &[u8]) -> std::io::Result { let s = String::from_utf8(buf.to_vec()).unwrap(); self.push_str(&s); Ok(s.len()) } fn flush(&mut self) -> std::io::Result<()> { Ok(()) } } impl FixedLengthGraphemeString { #[must_use] pub fn empty(max_grapheme_length: u16) -> FixedLengthGraphemeString { FixedLengthGraphemeString { string: String::new(), grapheme_length: 0, max_grapheme_length, } } pub fn new>(s: S, max_grapheme_length: u16) -> FixedLengthGraphemeString { let mut fixed_length_grapheme_string = FixedLengthGraphemeString::empty(max_grapheme_length); fixed_length_grapheme_string.push_grapheme_str(s); fixed_length_grapheme_string } pub fn push_grapheme_str>(&mut self, s: S) { for grapheme in s.into().graphemes(true) { if self.grapheme_length >= self.max_grapheme_length { return; } self.string.push_str(grapheme); self.grapheme_length += 1; } } pub fn push_str(&mut self, s: &str) { self.string.push_str(s); } } #[cfg(test)] mod tests { use super::FixedLengthGraphemeString; #[test] fn length_works() { let input = FixedLengthGraphemeString::new("こんにちは世界", 20); assert_eq!(input.grapheme_length, 7); } #[test] fn max_length_works() { let mut input = FixedLengthGraphemeString::new("こんにちは世界", 5); assert_eq!(input.string, "こんにちは"); input.push_grapheme_str("世界"); assert_eq!(input.string, "こんにちは"); input.max_grapheme_length = 7; input.push_grapheme_str("世界"); assert_eq!(input.string, "こんにちは世界"); } } ================================================ FILE: src/history/db_extensions.rs ================================================ use crate::history::history::Features; use crate::network::Network; use rusqlite::Connection; use rusqlite::functions::FunctionFlags; pub fn add_db_functions(db: &Connection) { let network = Network::default(); db.create_scalar_function( "nn_rank", 10, FunctionFlags::SQLITE_UTF8 | FunctionFlags::SQLITE_DETERMINISTIC, move |ctx| { let age_factor = ctx.get::(0)?; let length_factor = ctx.get::(1)?; let exit_factor = ctx.get::(2)?; let recent_failure_factor = ctx.get::(3)?; let selected_dir_factor = ctx.get::(4)?; let dir_factor = ctx.get::(5)?; let overlap_factor = ctx.get::(6)?; let immediate_overlap_factor = ctx.get::(7)?; let selected_occurrences_factor = ctx.get::(8)?; let occurrences_factor = ctx.get::(9)?; let features = Features { age_factor, length_factor, exit_factor, recent_failure_factor, selected_dir_factor, dir_factor, overlap_factor, immediate_overlap_factor, selected_occurrences_factor, occurrences_factor, }; Ok(network.output(&features)) }, ) .unwrap_or_else(|err| panic!("McFly error: Successful create_scalar_function ({err})")); } ================================================ FILE: src/history/history.rs ================================================ #![allow(clippy::module_inception)] use crate::cli::SortOrder; use crate::history::{db_extensions, schema}; use crate::network::Network; use crate::path_update_helpers; use crate::settings::{HistoryFormat, ResultFilter, ResultSort, Settings, TimeRange}; use crate::shell_history; use crate::simplified_command::SimplifiedCommand; use crate::time::to_datetime; use itertools::Itertools; use rusqlite::named_params; use rusqlite::types::ToSql; use rusqlite::{Connection, MappedRows, Row}; use serde::{Serialize, Serializer}; use std::cmp::Ordering; use std::io::Write; use std::path::PathBuf; use std::time::{Instant, SystemTime, UNIX_EPOCH}; use std::{fmt, fs, io}; #[derive(Debug, Clone, Default)] pub struct Features { pub age_factor: f64, pub length_factor: f64, pub exit_factor: f64, pub recent_failure_factor: f64, pub selected_dir_factor: f64, pub dir_factor: f64, pub overlap_factor: f64, pub immediate_overlap_factor: f64, pub selected_occurrences_factor: f64, pub occurrences_factor: f64, } #[derive(Debug, Clone, Default)] pub struct Command { pub id: i64, pub cmd: String, pub cmd_tpl: String, pub session_id: String, pub rank: f64, pub when_run: Option, pub last_run: Option, pub exit_code: Option, pub selected: bool, pub dir: Option, pub features: Features, pub match_indices: Vec, } #[derive(Debug, Clone, Serialize)] pub struct DumpCommand { pub cmd: String, #[serde(serialize_with = "ser_to_datetime")] pub when_run: i64, } impl fmt::Display for Command { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { self.cmd.fmt(f) } } impl From for String { fn from(command: Command) -> Self { command.cmd } } #[inline] fn ser_to_datetime(when_run: &i64, serializer: S) -> Result where S: Serializer, { serializer.serialize_str(&to_datetime(*when_run)) } #[derive(Debug)] pub struct History { pub connection: Connection, pub network: Network, } const IGNORED_COMMANDS: [&str; 7] = [ "pwd", "ls", "cd", "cd ..", "clear", "history", "mcfly search", ]; impl History { #[must_use] pub fn load(history_format: HistoryFormat) -> History { let db_path = Settings::mcfly_db_path(); let history = if db_path.exists() { History::from_db_path(db_path) } else { History::from_shell_history(history_format) }; schema::migrate(&history.connection); history } pub fn should_add(&self, command: &str) -> bool { // Ignore empty commands. if command.is_empty() { return false; } // Ignore commands added via a ctrl-r search. if command.starts_with("#mcfly:") { return false; } // Ignore commands with a leading space. if command.starts_with(' ') { return false; } // Ignore blacklisted commands. if IGNORED_COMMANDS.contains(&command) { return false; } // Ignore the previous command (independent of Session ID) so that opening a new terminal // window won't replay the last command in the history. let last_command = self.last_command(&None); if last_command.is_none() { return true; } !command.eq(&last_command.unwrap().cmd) } pub fn add( &self, command: &str, session_id: &str, dir: &str, when_run: &Option, exit_code: Option, old_dir: &Option, ) { self.possibly_update_paths(command, exit_code); let selected = self.determine_if_selected_from_ui(command, session_id, dir); let simplified_command = SimplifiedCommand::new(command, true); self.connection.execute("INSERT INTO commands (cmd, cmd_tpl, session_id, when_run, exit_code, selected, dir, old_dir) VALUES (:cmd, :cmd_tpl, :session_id, :when_run, :exit_code, :selected, :dir, :old_dir)", named_params!{ ":cmd": &command.to_owned(), ":cmd_tpl": &simplified_command.result, ":session_id": &session_id.to_owned(), ":when_run": &when_run.to_owned(), ":exit_code": &exit_code.clone(), ":selected": &selected, ":dir": &dir.to_owned(), ":old_dir": &old_dir.to_owned(), }).unwrap_or_else(|err| panic!("McFly error: Insert into commands to work ({err})")); } fn determine_if_selected_from_ui(&self, command: &str, session_id: &str, dir: &str) -> bool { let rows_affected = self .connection .execute( "DELETE FROM selected_commands \ WHERE cmd = :cmd \ AND session_id = :session_id \ AND dir = :dir", &[ (":cmd", &command.to_owned()), (":session_id", &session_id.to_owned()), (":dir", &dir.to_owned()), ], ) .unwrap_or_else(|err| { panic!("McFly error: DELETE from selected_commands to work ({err})") }); // Delete any other pending selected commands for this session -- they must have been aborted or edited. self.connection .execute( "DELETE FROM selected_commands WHERE session_id = :session_id", &[(":session_id", &session_id.to_owned())], ) .unwrap_or_else(|err| { panic!("McFly error: DELETE from selected_commands to work ({err})") }); rows_affected > 0 } pub fn record_selected_from_ui(&self, command: &str, session_id: &str, dir: &str) { self.connection.execute("INSERT INTO selected_commands (cmd, session_id, dir) VALUES (:cmd, :session_id, :dir)", &[ (":cmd", &command.to_owned()), (":session_id", &session_id.to_owned()), (":dir", &dir.to_owned()) ]).unwrap_or_else(|err| panic!("McFly error: Insert into selected_commands to work ({err})")); } // Update historical paths in our database if a directory has been renamed or moved. pub fn possibly_update_paths(&self, command: &str, exit_code: Option) { let successful = exit_code.is_none() || exit_code.unwrap() == 0; let is_move = |c: &str| c.to_lowercase().starts_with("mv ") && !c.contains('*') && !c.contains('?'); if successful && is_move(command) { let parts = path_update_helpers::parse_mv_command(command); if parts.len() == 2 { let normalized_from = path_update_helpers::normalize_path(&parts[0]); let normalized_to = path_update_helpers::normalize_path(&parts[1]); // If $to/$(base_name($from)) exists, and is a directory, assume we've moved $from into $to. // If not, assume we've renamed $from to $to. if let Some(basename) = PathBuf::from(&normalized_from).file_name() { if let Some(utf8_basename) = basename.to_str() { if utf8_basename.contains('.') { // It was probably a file. return; } let maybe_moved_directory = PathBuf::from(&normalized_to).join(utf8_basename); if maybe_moved_directory.exists() { if maybe_moved_directory.is_dir() { self.update_paths( &normalized_from, maybe_moved_directory.to_str().unwrap(), false, ); } else { // The source must have been a file, so ignore it. } return; } } else { // Don't try to handle non-utf8 filenames, at least for now. return; } } let to_pathbuf = PathBuf::from(&normalized_to); if to_pathbuf.exists() && to_pathbuf.is_dir() { self.update_paths(&normalized_from, &normalized_to, false); } } } } pub fn find_matches( &self, cmd: &str, num: i16, fuzzy: i16, result_sort: &ResultSort, ) -> Vec { let (wildcard, match_function, cmd) = if Self::is_case_sensitive(cmd) { // escape '*' with '[*]' and replace '%' with '*' for glob matching ("*", "GLOB", cmd.replace("*", "[*]").replace("%", "*")) } else { ("%", "LIKE", cmd.to_string()) }; let mut like_query = wildcard.to_string(); if fuzzy > 0 { like_query.push_str(&cmd.split("").collect::>().join(wildcard)); } else { like_query.push_str(&cmd); } like_query += wildcard; let order_by_column: &str = match &result_sort { ResultSort::LastRun => "last_run", _ => "rank", }; let query: &str = &format!( "{} {} {} {} {}", "SELECT id, cmd, cmd_tpl, session_id, when_run, exit_code, selected, dir, rank, age_factor, length_factor, exit_factor, recent_failure_factor, selected_dir_factor, dir_factor, overlap_factor, immediate_overlap_factor, selected_occurrences_factor, occurrences_factor, last_run FROM contextual_commands WHERE cmd", match_function, "(:like) ORDER BY", order_by_column, "DESC LIMIT :limit" )[..]; let mut statement = self .connection .prepare(query) .unwrap_or_else(|err| panic!("McFly error: Prepare to work ({err})")); let command_iter = statement .query_map( named_params! { ":like": &like_query, ":limit": &num }, |row| { let text: String = row .get(1) .unwrap_or_else(|err| panic!("McFly error: cmd to be readable ({err})")); let bounds = Self::calc_match_indices(&text, &cmd, fuzzy); Ok(Command { id: row.get(0).unwrap_or_else(|err| { panic!("McFly error: id to be readable ({err})") }), cmd: text, cmd_tpl: row.get(2).unwrap_or_else(|err| { panic!("McFly error: cmd_tpl to be readable ({err})") }), session_id: row.get(3).unwrap_or_else(|err| { panic!("McFly error: session_id to be readable ({err})") }), when_run: row.get(4).unwrap_or_else(|err| { panic!("McFly error: when_run to be readable ({err})") }), exit_code: row.get(5).unwrap_or_else(|err| { panic!("McFly error: exit_code to be readable ({err})") }), selected: row.get(6).unwrap_or_else(|err| { panic!("McFly error: selected to be readable ({err})") }), dir: row.get(7).unwrap_or_else(|err| { panic!("McFly error: dir to be readable ({err})") }), rank: row.get(8).unwrap_or_else(|err| { panic!("McFly error: rank to be readable ({err})") }), match_indices: bounds, features: Features { age_factor: row.get(9).unwrap_or_else(|err| { panic!("McFly error: age_factor to be readable ({err})") }), length_factor: row.get(10).unwrap_or_else(|err| { panic!("McFly error: length_factor to be readable ({err})") }), exit_factor: row.get(11).unwrap_or_else(|err| { panic!("McFly error: exit_factor to be readable ({err})") }), recent_failure_factor: row.get(12).unwrap_or_else(|err| { panic!( "McFly error: recent_failure_factor to be readable ({err})" ) }), selected_dir_factor: row.get(13).unwrap_or_else(|err| { panic!("McFly error: selected_dir_factor to be readable ({err})") }), dir_factor: row.get(14).unwrap_or_else(|err| { panic!("McFly error: dir_factor to be readable ({err})") }), overlap_factor: row.get(15).unwrap_or_else(|err| { panic!("McFly error: overlap_factor to be readable ({err})") }), immediate_overlap_factor: row.get(16).unwrap_or_else(|err| { panic!( "McFly error: immediate_overlap_factor to be readable ({err})" ) }), selected_occurrences_factor: row.get(17).unwrap_or_else(|err| { panic!( "McFly error: selected_occurrences_factor to be readable ({err})" ) }), occurrences_factor: row.get(18).unwrap_or_else(|err| { panic!("McFly error: occurrences_factor to be readable ({err})") }), }, last_run: row.get(19).unwrap_or_else(|err| { panic!("McFly error: last_run to be readable ({err})") }), }) }, ) .unwrap_or_else(|err| panic!("McFly error: Query Map to work ({err})")); let mut names = Vec::new(); for result in command_iter { names.push(result.unwrap_or_else(|err| { panic!("McFly error: Unable to load command from DB ({err})") })); } if fuzzy > 0 && result_sort != &ResultSort::LastRun { names = names .into_iter() .sorted_unstable_by(|a, b| { // Fuzzy matches impose new ordering criteria on top of the // natural rank-based sorting: at the most basic level, // shorter and earlier matches are more likely to be // desired than longer or later matches -- even if they are // ranked a little lower. // // Each match is weighted by the inverse of its length plus // start position, relative to the total length + start of // both matches added together. This yields two complements // which always add up to 1 (e.g. 0.6 vs 0.4). If both // matches have the same length and start position, or if // those balance out exactly, the resulting weights will // both equal 0.5. // // The weights are multiplied by the configurable fuzzy // factor before being added to each result's original // rank. Factors > 1 are a "thumb on the scale" increasing // the likelihood of the weight flipping the outcome for // the originally lower-ranked result. let a_start = *a.match_indices.first().unwrap_or(&0); let b_start = *b.match_indices.first().unwrap_or(&0); let a_len = a.match_indices.last().map(|i| i + 1).unwrap_or(0) - a_start; let b_len = b.match_indices.last().map(|i| i + 1).unwrap_or(0) - b_start; let a_mod = 1.0 - (a_start + a_len) as f64 / (a_start + b_start + a_len + b_len) as f64; let b_mod = 1.0 - (b_start + b_len) as f64 / (a_start + b_start + a_len + b_len) as f64; PartialOrd::partial_cmp( &(b.rank + b_mod * f64::from(fuzzy)), &(a.rank + a_mod * f64::from(fuzzy)), ) .unwrap_or(Ordering::Equal) }) .collect(); } names } /// Enable case sensitivity when input string contains uppercase fn is_case_sensitive(cmd: &str) -> bool { cmd.chars().any(|c| c.is_uppercase()) } /// Calculate the indices of the matches in the text. fn calc_match_indices(text: &str, cmd: &str, fuzzy: i16) -> Vec { let (text, cmd) = if Self::is_case_sensitive(cmd) { (text.to_string(), cmd.to_string()) } else { (text.to_lowercase(), cmd.to_lowercase()) }; match fuzzy { 0 => text .match_indices(&cmd) .flat_map(|(index, _)| index..index + cmd.len()) .collect(), _ => { let mut search_iter = cmd.chars().peekable(); text.match_indices(|c| { let next = search_iter.peek(); if next.is_some() && next.unwrap() == &c { let _advance = search_iter.next(); return true; } false }) .map(|m| m.0) .collect() } } } #[allow(clippy::too_many_arguments)] pub fn build_cache_table( &self, dir: &str, result_filter: &ResultFilter, session_id: &Option, start_time: Option, end_time: Option, now: Option, limit: Option, ) { let lookback: u16 = 3; let mut last_commands = self.last_command_templates(session_id, lookback as i16, 0); if last_commands.len() < lookback as usize { last_commands = self.last_command_templates(&None, lookback as i16, 0); while last_commands.len() < lookback as usize { last_commands.push(String::new()); } } #[allow(unused_variables)] let beginning_of_execution = Instant::now(); self.connection .execute("PRAGMA temp_store = MEMORY;", []) .unwrap(); self.connection .execute("DROP TABLE IF EXISTS temp.contextual_commands;", []) .unwrap_or_else(|err| panic!("McFly error: Removal of temp table to work ({err})")); let (mut when_run_min, when_run_max): (f64, f64) = self .connection .query_row( "SELECT IFNULL(MIN(when_run), 0), IFNULL(MAX(when_run), 0) FROM commands", [], |row| Ok((row.get_unwrap(0), row.get_unwrap(1))), ) .unwrap_or_else(|err| panic!("McFly error: Query to work ({err})")); if (when_run_min - when_run_max).abs() < f64::EPSILON { when_run_min -= 60.0 * 60.0; } let max_occurrences: f64 = self .connection .query_row( "SELECT COUNT(*) AS c FROM commands GROUP BY cmd ORDER BY c DESC LIMIT 1", [], |row| row.get(0), ) .unwrap_or(1.0); let max_selected_occurrences: f64 = self.connection .query_row("SELECT COUNT(*) AS c FROM commands WHERE selected = 1 GROUP BY cmd ORDER BY c DESC LIMIT 1", [], |row| row.get(0)).unwrap_or(1.0); // FIXME: 1.0 seems wrong. let max_length: f64 = self .connection .query_row( "SELECT IFNULL(MAX(LENGTH(cmd)), 0) FROM commands", [], |row| row.get(0), ) .unwrap_or(100.0); let max_id: i64 = self .connection .query_row("SELECT IFNULL(MAX(id), 0) FROM commands", [], |row| { row.get(0) }) .unwrap_or(0); let min_id = if let Some(limit_value) = limit { if limit_value > max_id { 0 } else { (max_id as f64 * (1.0 - (limit_value as f64 / max_id as f64))) as i64 } } else { 0 }; let dir_filter_off = match &result_filter { ResultFilter::Global => true, ResultFilter::CurrentDirectory => false, }; self.connection.execute( "CREATE TEMP TABLE contextual_commands AS SELECT id, cmd, cmd_tpl, session_id, when_run, MAX(when_run) AS last_run, exit_code, selected, dir, /* to be filled in later */ 0.0 AS rank, /* length of the command string */ LENGTH(c.cmd) / :max_length AS length_factor, /* age of the last execution of this command (0.0 is new, 1.0 is old) */ MIN((:when_run_max - when_run) / :history_duration) AS age_factor, /* average error state (1: always successful, 0: always errors) */ SUM(CASE WHEN exit_code = 0 THEN 1.0 ELSE 0.0 END) / COUNT(*) as exit_factor, /* recent failure (1 if failed recently, 0 if not) */ MAX(CASE WHEN exit_code != 0 AND :now - when_run < 120 THEN 1.0 ELSE 0.0 END) AS recent_failure_factor, /* percentage run in this directory (1: always run in this directory, 0: never run in this directory) */ SUM(CASE WHEN dir = :directory THEN 1.0 ELSE 0.0 END) / COUNT(*) as dir_factor, /* percentage of time selected in this directory (1: only selected in this dir, 0: only selected elsewhere) */ SUM(CASE WHEN dir = :directory AND selected = 1 THEN 1.0 ELSE 0.0 END) / (SUM(CASE WHEN selected = 1 THEN 1.0 ELSE 0.0 END) + 1) as selected_dir_factor, /* average contextual overlap of this command (0: none of the last 3 commands has ever overlapped with this command, 1: all of the last three commands always overlap with this command) */ SUM(( SELECT COUNT(DISTINCT c2.cmd_tpl) FROM commands c2 WHERE c2.id >= c.id - :lookback AND c2.id < c.id AND c2.cmd_tpl IN (:last_commands0, :last_commands1, :last_commands2) ) / :lookback_f64) / COUNT(*) AS overlap_factor, /* average overlap with the last command (0: this command never follows the last command, 1: this command always follows the last command) */ SUM((SELECT COUNT(*) FROM commands c2 WHERE c2.id = c.id - 1 AND c2.cmd_tpl = :last_commands0)) / COUNT(*) AS immediate_overlap_factor, /* percentage selected (1: this is the most commonly selected command, 0: this command is never selected) */ SUM(CASE WHEN selected = 1 THEN 1.0 ELSE 0.0 END) / :max_selected_occurrences AS selected_occurrences_factor, /* percentage of time this command is run relative to the most common command (1: this is the most common command, 0: this is the least common command) */ COUNT(*) / :max_occurrences AS occurrences_factor FROM commands c WHERE id > :min_id AND when_run > :start_time AND when_run < :end_time AND (:dir_filter_off OR dir LIKE :directory) GROUP BY cmd ORDER BY id DESC;", named_params! { ":when_run_max": &when_run_max, ":history_duration": &(when_run_max - when_run_min), ":directory": &dir.to_owned(), ":dir_filter_off": &dir_filter_off, ":max_occurrences": &max_occurrences, ":max_length": &max_length, ":max_selected_occurrences": &max_selected_occurrences, ":lookback": &lookback, ":lookback_f64": &f64::from(lookback), ":last_commands0": &last_commands[0].clone(), ":last_commands1": &last_commands[1].clone(), ":last_commands2": &last_commands[2].clone(), ":start_time": &start_time.unwrap_or(0).to_owned(), ":end_time": &end_time.unwrap_or(SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_else(|err| panic!("McFly error: Time went backwards ({err})")).as_secs() as i64).to_owned(), ":now": &now.unwrap_or(SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_else(|err| panic!("McFly error: Time went backwards ({err})")).as_secs() as i64).to_owned(), ":min_id": &min_id, }).unwrap_or_else(|err| panic!("McFly error: Creation of temp table to work ({err})")); self.connection .execute( "UPDATE contextual_commands SET rank = nn_rank(age_factor, length_factor, exit_factor, recent_failure_factor, selected_dir_factor, dir_factor, overlap_factor, immediate_overlap_factor, selected_occurrences_factor, occurrences_factor);", [], ) .unwrap_or_else(|err| panic!("McFly error: Ranking of temp table to work ({err})")); self.connection .execute("CREATE INDEX temp.MyIndex ON contextual_commands(id);", []) .unwrap_or_else(|err| { panic!("McFly error: Creation of index on temp table to work ({err})") }); // println!("Seconds: {}", (beginning_of_execution.elapsed().as_secs() as f64) + (beginning_of_execution.elapsed().subsec_nanos() as f64 / 1000_000_000.0)); } pub fn commands( &self, session_id: &Option, num: i16, offset: u16, random: bool, ) -> Vec { let order = if random { "RANDOM()" } else { "id" }; let query = if session_id.is_none() { format!( "SELECT id, cmd, cmd_tpl, session_id, when_run, exit_code, selected, dir FROM commands ORDER BY {order} DESC LIMIT :limit OFFSET :offset" ) } else { format!( "SELECT id, cmd, cmd_tpl, session_id, when_run, exit_code, selected, dir FROM commands WHERE session_id = :session_id ORDER BY {order} DESC LIMIT :limit OFFSET :offset" ) }; let closure: fn(&Row) -> rusqlite::Result = |row| { Ok(Command { id: row.get(0)?, cmd: row.get(1)?, cmd_tpl: row.get(2)?, session_id: row.get(3)?, when_run: row.get(4)?, exit_code: row.get(5)?, selected: row.get(6)?, dir: row.get(7)?, ..Command::default() }) }; if session_id.is_none() { self.run_query(&query, &[(":limit", &num), (":offset", &offset)], closure) } else { self.run_query( &query, &[ (":session_id", &session_id.to_owned().unwrap()), (":limit", &num), (":offset", &offset), ], closure, ) } } pub fn run_query(&self, query: &str, params: &[(&str, &dyn ToSql)], f: F) -> Vec where F: FnMut(&Row<'_>) -> rusqlite::Result, { let mut statement = self.connection.prepare(query).unwrap(); let rows: MappedRows<_> = statement .query_map(params, f) .unwrap_or_else(|err| panic!("McFly error: Query Map to work ({err})")); let mut vec: Vec = Vec::new(); for row in rows.flatten() { vec.push(row); } vec } pub fn last_command(&self, session_id: &Option) -> Option { self.commands(session_id, 1, 0, false).first().cloned() } pub fn last_command_templates( &self, session_id: &Option, num: i16, offset: u16, ) -> Vec { self.commands(session_id, num, offset, false) .iter() .map(|command| command.cmd_tpl.clone()) .collect() } pub fn delete_command(&self, command: &str) { self.connection .execute( "DELETE FROM selected_commands WHERE cmd = :command", &[(":command", &command)], ) .unwrap_or_else(|err| { panic!("McFly error: DELETE from selected_commands to work ({err})") }); self.connection .execute( "DELETE FROM commands WHERE cmd = :command", &[(":command", &command)], ) .unwrap_or_else(|err| panic!("McFly error: DELETE from commands to work ({err})")); } pub fn update_paths(&self, old_path: &str, new_path: &str, print_output: bool) { let normalized_old_path = path_update_helpers::normalize_path(old_path); let normalized_new_path = path_update_helpers::normalize_path(new_path); if normalized_old_path.len() > 1 && normalized_new_path.len() > 1 { let like_query = normalized_old_path.to_string() + "/%"; let mut dir_update_statement = self.connection.prepare( "UPDATE commands SET dir = :new_dir || SUBSTR(dir, :length) WHERE dir = :exact OR dir LIKE (:like)" ).unwrap(); let mut old_dir_update_statement = self.connection.prepare( "UPDATE commands SET old_dir = :new_dir || SUBSTR(old_dir, :length) WHERE old_dir = :exact OR old_dir LIKE (:like)" ).unwrap(); let affected = dir_update_statement .execute(named_params! { ":like": &like_query, ":exact": &normalized_old_path, ":new_dir": &normalized_new_path, ":length": &(normalized_old_path.chars().count() as u32 + 1), }) .unwrap_or_else(|err| panic!("McFly error: dir UPDATE to work ({err})")); old_dir_update_statement .execute(named_params! { ":like": &like_query, ":exact": &normalized_old_path, ":new_dir": &normalized_new_path, ":length": &(normalized_old_path.chars().count() as u32 + 1), }) .unwrap_or_else(|err| panic!("McFly error: old_dir UPDATE to work ({err})")); if print_output { println!( "McFly: Command database paths renamed from {normalized_old_path} to {normalized_new_path} (affected {affected} commands)" ); } } else if print_output { println!("McFly: Not updating paths due to invalid options."); } } pub fn dump(&self, time_range: &TimeRange, order: &SortOrder) -> Vec { let mut where_clause = String::new(); // Were there condtions in where clause? let mut has_conds = false; let mut params: Vec<(&str, &dyn ToSql)> = Vec::with_capacity(2); if !time_range.is_full() { where_clause.push_str("WHERE"); if let Some(since) = &time_range.since { where_clause.push_str(" :since <= when_run"); has_conds = true; params.push((":since", since)); } if let Some(before) = &time_range.before { if has_conds { where_clause.push_str(" AND"); } where_clause.push_str(" when_run < :before"); params.push((":before", before)); } } let query = format!( "SELECT cmd, when_run FROM commands {} ORDER BY when_run {}", where_clause, order.to_str() ); self.run_query(&query, params.as_slice(), |row| { Ok(DumpCommand { cmd: row.get(0)?, when_run: row.get(1)?, }) }) } fn from_shell_history(history_format: HistoryFormat) -> History { print!( "McFly: Importing shell history for the first time. This may take a minute or two..." ); io::stdout() .flush() .unwrap_or_else(|err| panic!("McFly error: STDOUT flush should work ({err})")); // Load this first to make sure it works before we create the DB. let commands = shell_history::full_history(&shell_history::history_file_path(), history_format); // Use ~/.mcfly if it already exists, or create 'mcfly' folder in XDG_DATA_DIR let mcfly_db_path = Settings::mcfly_db_path(); let mcfly_db_dir = mcfly_db_path.parent().unwrap(); fs::create_dir_all(mcfly_db_dir) .unwrap_or_else(|_| panic!("Unable to create {mcfly_db_dir:?}")); // Make ~/.mcfly/history.db let mut connection = Connection::open(&mcfly_db_path) .unwrap_or_else(|_| panic!("Unable to create history DB at {:?}", &mcfly_db_path)); db_extensions::add_db_functions(&connection); connection.execute_batch( "CREATE TABLE commands( \ id INTEGER PRIMARY KEY AUTOINCREMENT, \ cmd TEXT NOT NULL, \ cmd_tpl TEXT, \ session_id TEXT NOT NULL, \ when_run INTEGER NOT NULL, \ exit_code INTEGER NOT NULL, \ selected INTEGER NOT NULL, \ dir TEXT, \ old_dir TEXT \ ); \ CREATE INDEX command_cmds ON commands (cmd);\ CREATE INDEX command_session_id ON commands (session_id);\ CREATE INDEX command_dirs ON commands (dir);\ \ CREATE TABLE selected_commands( \ id INTEGER PRIMARY KEY AUTOINCREMENT, \ cmd TEXT NOT NULL, \ session_id TEXT NOT NULL, \ dir TEXT NOT NULL \ ); \ CREATE INDEX selected_command_session_cmds ON selected_commands (session_id, cmd);" ).unwrap_or_else(|err| panic!("McFly error: Unable to initialize history db ({err})")); let transaction = connection .transaction() .unwrap_or_else(|err| panic!("McFly error: Unable to begin transaction ({err})")); { let mut statement = transaction .prepare("INSERT INTO commands (cmd, cmd_tpl, session_id, when_run, exit_code, selected) VALUES (:cmd, :cmd_tpl, :session_id, :when_run, :exit_code, :selected)") .unwrap_or_else(|err| panic!("McFly error: Unable to prepare insert ({err})")); for command in commands { if !IGNORED_COMMANDS.contains(&command.command.as_str()) { let simplified_command = SimplifiedCommand::new(&command.command, true); if !command.command.is_empty() && !simplified_command.result.is_empty() && let Err(e) = statement.execute(named_params! { ":cmd": &command.command, ":cmd_tpl": &simplified_command.result.clone(), ":session_id": &"IMPORTED", ":when_run": &command.when, ":exit_code": &0, ":selected": &0, }) { println!( "A single history line could not be saved due to '{}' (command was '{}'), but other inserts should be fine.", e, &command.command ); } } } } transaction .commit() .unwrap_or_else(|err| panic!("McFly error: Unable to commit transaction: ({err})")); schema::first_time_setup(&connection); println!("done."); History { connection, network: Network::default(), } } fn from_db_path(path: PathBuf) -> History { let connection = Connection::open(path) .unwrap_or_else(|err| panic!("McFly error: Unable to open history database ({err})")); db_extensions::add_db_functions(&connection); History { connection, network: Network::default(), } } } ================================================ FILE: src/history/mod.rs ================================================ pub use self::history::{Command, DumpCommand, Features, History}; mod db_extensions; mod history; mod schema; ================================================ FILE: src/history/schema.rs ================================================ use crate::simplified_command::SimplifiedCommand; use rusqlite::{Connection, named_params}; use std::io; use std::io::Write; pub const CURRENT_SCHEMA_VERSION: u16 = 3; pub fn first_time_setup(connection: &Connection) { make_schema_versions_table(connection); write_current_schema_version(connection); } pub fn migrate(connection: &Connection) { make_schema_versions_table(connection); let current_version: u16 = connection .query_row::, _, _>( "select max(version) FROM schema_versions ORDER BY version DESC LIMIT 1", [], |row| row.get(0), ) .unwrap_or_else(|err| panic!("McFly error: Query to work ({err})")) .unwrap_or(0); assert!( current_version <= CURRENT_SCHEMA_VERSION, "McFly error: Database schema version ({current_version}) is newer than the max version supported by this binary ({CURRENT_SCHEMA_VERSION}). You should update mcfly.", ); if current_version < CURRENT_SCHEMA_VERSION { print!("McFly: Upgrading McFly DB to version {CURRENT_SCHEMA_VERSION}, please wait..."); io::stdout() .flush() .unwrap_or_else(|err| panic!("McFly error: STDOUT flush should work ({err})")); } if current_version < 1 { connection .execute_batch( "ALTER TABLE commands ADD COLUMN cmd_tpl TEXT; UPDATE commands SET cmd_tpl = '';", ) .unwrap_or_else(|err| panic!("McFly error: Unable to add cmd_tpl to commands ({err})")); let mut statement = connection .prepare("UPDATE commands SET cmd_tpl = :cmd_tpl WHERE id = :id") .unwrap_or_else(|err| panic!("McFly error: Unable to prepare update ({err})")); for (id, cmd) in cmd_strings(connection) { let simplified_command = SimplifiedCommand::new(cmd.as_str(), true); statement .execute(named_params! { ":cmd_tpl": &simplified_command.result, ":id": &id }) .unwrap_or_else(|err| panic!("McFly error: Insert to work ({err})")); } } if current_version < 2 { connection .execute_batch( "ALTER TABLE commands ADD COLUMN session_id TEXT; \ UPDATE commands SET session_id = 'UNKNOWN'; \ CREATE INDEX command_session_id ON commands (session_id);", ) .unwrap_or_else(|err| { panic!("McFly error: Unable to add session_id to commands ({err})") }); } if current_version < 3 { connection .execute_batch( "CREATE TABLE selected_commands( \ id INTEGER PRIMARY KEY AUTOINCREMENT, \ cmd TEXT NOT NULL, \ session_id TEXT NOT NULL, \ dir TEXT NOT NULL \ ); \ CREATE INDEX selected_command_session_cmds ON selected_commands (session_id, cmd); \ \ ALTER TABLE commands ADD COLUMN selected INTEGER; \ UPDATE commands SET selected = 0;", ) .unwrap_or_else(|err| panic!("McFly error: Unable to add selected_commands ({err})")); } if current_version < CURRENT_SCHEMA_VERSION { println!("done."); write_current_schema_version(connection); } } fn make_schema_versions_table(connection: &Connection) { connection .execute_batch( "CREATE TABLE IF NOT EXISTS schema_versions( \ id INTEGER PRIMARY KEY AUTOINCREMENT, \ version INTEGER NOT NULL, \ when_run INTEGER NOT NULL); CREATE UNIQUE INDEX IF NOT EXISTS schema_versions_index ON schema_versions (version);", ) .unwrap_or_else(|err| { panic!("McFly error: Unable to create schema_versions db table ({err})") }); } fn write_current_schema_version(connection: &Connection) { let insert = format!( "INSERT INTO schema_versions (version, when_run) VALUES ({CURRENT_SCHEMA_VERSION}, strftime('%s','now'))" ); connection .execute_batch(&insert) .unwrap_or_else(|err| panic!("McFly error: Unable to update schema_versions ({err})")); } fn cmd_strings(connection: &Connection) -> Vec<(i64, String)> { let query = "SELECT id, cmd FROM commands ORDER BY id DESC"; let mut statement = connection.prepare(query).unwrap(); let command_iter = statement .query_map([], |row| Ok((row.get_unwrap(0), row.get_unwrap(1)))) .unwrap_or_else(|err| panic!("McFly error: Query Map to work ({err})")); let mut vec = Vec::new(); for command in command_iter.flatten() { vec.push(command); } vec } ================================================ FILE: src/history_cleaner.rs ================================================ use crate::history::History; use crate::settings::{HistoryFormat, Settings}; use crate::shell_history; use std::env; use std::fs; use std::path::{Path, PathBuf}; pub fn clean(settings: &Settings, history: &History, command: &str) { // Clean up the database. history.delete_command(command); match settings.history_format { HistoryFormat::Bash | HistoryFormat::Zsh { .. } => { // Clean up the contents of MCFLY_HISTORY and all other temporary history files in the same // directory. clean_temporary_files(&settings.mcfly_history, settings.history_format, command); // Clean up HISTFILE/MCFLY_HISTFILE. let histfile = PathBuf::from( env::var("HISTFILE") .or_else(|_| env::var("MCFLY_HISTFILE")) .unwrap_or_else(|err| { panic!( "McFly error: Please ensure that HISTFILE/MCFLY_HISTFILE is set ({err})" ) }), ); shell_history::delete_lines(&histfile, settings.history_format, command); } // Fish integration does not use a MCFLY_HISTORY file because we can get the last command // during a fish_postexec function. // Also, deletion from the fish history file is done by fish itself, via commands sent out // in the results file and interpreted by mcfly.fish. HistoryFormat::Fish => {} } } fn clean_temporary_files(mcfly_history: &Path, history_format: HistoryFormat, command: &str) { let path = mcfly_history; if let Some(directory) = path.parent() { let expanded_path = fs::canonicalize(directory).unwrap_or_else(|err| { panic!("McFly error: The contents of $MCFLY_HISTORY appear invalid ({err})") }); let paths = fs::read_dir(expanded_path).unwrap(); for entry in paths.flatten() { if let Some(file_name) = entry.path().file_name() && let Some(valid_unicode_str) = file_name.to_str() && valid_unicode_str.starts_with("mcfly.") { shell_history::delete_lines(&entry.path(), history_format, command); } } } } ================================================ FILE: src/init.rs ================================================ use super::settings::InitMode; use std::env; pub struct Init {} impl Init { pub fn new(init_mode: &InitMode) -> Self { match init_mode { InitMode::Bash => { Init::init_bash(); } InitMode::Zsh => { Init::init_zsh(); } InitMode::Fish => { Init::init_fish(); } InitMode::Powershell => { Init::init_pwsh(); } } Self {} } pub fn init_bash() { let script = include_str!("../mcfly.bash"); print!("{script}"); } pub fn init_zsh() { let script = include_str!("../mcfly.zsh"); print!("{script}"); } pub fn init_fish() { let script = include_str!("../mcfly.fish"); print!("{script}"); } pub fn init_pwsh() { let script = include_str!("../mcfly.ps1") .replace("::MCFLY::", env::current_exe().unwrap().to_str().unwrap()); print!("{script}"); } } ================================================ FILE: src/interface.rs ================================================ use crate::command_input::{CommandInput, Move}; use crate::history::History; use crate::fixed_length_grapheme_string::FixedLengthGraphemeString; use crate::history::Command; use crate::history_cleaner; use crate::settings::{InterfaceView, KeyScheme, ResultFilter}; use crate::settings::{ResultSort, Settings}; use chrono::{Duration, TimeZone, Utc}; use crossterm::event::KeyCode::Char; use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, read}; use crossterm::style::{Color, Print, SetBackgroundColor, SetForegroundColor}; use crossterm::terminal::{self, LeaveAlternateScreen}; use crossterm::terminal::{Clear, ClearType, EnterAlternateScreen}; use crossterm::{cursor, execute, queue}; use humantime::format_duration; use std::io::{Write, stdout}; use std::string::String; pub struct Interface<'a> { history: &'a History, settings: &'a Settings, input: CommandInput, selection: usize, matches: Vec, debug: bool, run: bool, delete_requests: Vec, menu_mode: MenuMode, in_vim_insert_mode: bool, result_sort: ResultSort, result_filter: ResultFilter, } pub struct SelectionResult { /// Whether the user requested to run the resulting command immediately. pub run: bool, /// The command string the user selected, if any. pub selection: Option, /// Commands the user has requested be deleted from shell history. pub delete_requests: Vec, } pub enum MoveSelection { Up, Down, } #[derive(PartialEq, Eq)] pub enum MenuMode { Normal, ConfirmDelete, } impl MenuMode { fn text(&self, interface: &Interface) -> String { let mut menu_text = String::from("McFly"); match *self { MenuMode::Normal => match interface.settings.key_scheme { KeyScheme::Emacs => menu_text.push_str(" | ESC - Exit | "), KeyScheme::Vim => { if interface.in_vim_insert_mode { menu_text.push_str(" (Ins) | ESC - Cmd | "); } else { menu_text.push_str(" (Cmd) | ESC - Exit | "); } } }, MenuMode::ConfirmDelete => { return String::from("Delete selected command from the history? (Y/N)"); } } if interface.settings.disable_run_command { menu_text.push_str("⏎, TAB - Edit | "); } else { menu_text.push_str("⏎ - Run | TAB - Edit | "); } match interface.result_sort { ResultSort::Rank => menu_text.push_str("F1 - Rank Sort | "), ResultSort::LastRun => menu_text.push_str("F1 - Time Sort | "), } menu_text.push_str("F2 - Delete | "); match interface.result_filter { ResultFilter::Global => menu_text.push_str("F3 - All Directories"), ResultFilter::CurrentDirectory => menu_text.push_str("F3 - This Directory"), } menu_text } fn bg(&self, normal: Color) -> Color { match *self { MenuMode::Normal => normal, MenuMode::ConfirmDelete => Color::Red, } } } const PROMPT_LINE_INDEX: u16 = 3; const INFO_LINE_INDEX: u16 = 1; const RESULTS_TOP_INDEX: u16 = 5; impl<'a> Interface<'a> { pub fn new(settings: &'a Settings, history: &'a History) -> Interface<'a> { Interface { history, settings, input: CommandInput::from(settings.command.clone()), selection: 0, matches: Vec::new(), debug: settings.debug, run: false, delete_requests: Vec::new(), menu_mode: MenuMode::Normal, in_vim_insert_mode: true, result_sort: settings.result_sort.clone(), result_filter: settings.result_filter.clone(), } } pub fn display(&mut self) -> SelectionResult { self.build_cache_table(); self.select(); let command = self.input.command.clone(); if command.chars().any(|c| !c.is_whitespace()) { self.history.record_selected_from_ui( &command, &self.settings.session_id, &self.settings.dir, ); SelectionResult { run: self.run, selection: Some(command), // Remove delete_requests from the Interface, in case it's used to display() again. delete_requests: self.delete_requests.split_off(0), } } else { SelectionResult { run: self.run, selection: None, delete_requests: self.delete_requests.split_off(0), } } } fn build_cache_table(&self) { self.history.build_cache_table( &self.settings.dir.clone(), &self.result_filter, &Some(self.settings.session_id.clone()), None, None, None, self.settings.limit, ); } fn menubar(&self, screen: &mut W) { if !self.settings.disable_menu { let (width, _height): (u16, u16) = terminal::size().unwrap(); queue!( screen, cursor::Hide, cursor::MoveTo(0, self.info_line_index()), Clear(ClearType::CurrentLine), SetBackgroundColor(self.menu_mode.bg(self.settings.colors.menubar_bg)), SetForegroundColor(self.settings.colors.menubar_fg), cursor::MoveTo(1, self.info_line_index()), Print(format!( "{text:width$}", text = self.menu_mode.text(self), width = width as usize - 1 )), SetBackgroundColor(Color::Reset) ) .unwrap(); } } fn prompt(&self, screen: &mut W) { let prompt_line_index = self.prompt_line_index(); let fg = if self.settings.lightmode { self.settings.colors.lightmode_colors.prompt } else { self.settings.colors.darkmode_colors.prompt }; queue!( screen, cursor::MoveTo(1, prompt_line_index), SetForegroundColor(fg), Clear(ClearType::CurrentLine), Print(format!("{} {}", self.settings.prompt, self.input)), cursor::MoveTo(self.input.cursor as u16 + 3, prompt_line_index), cursor::Show ) .unwrap(); } fn debug_cursor(&self, screen: &mut W) { let result_top_index = self.result_top_index(); queue!( screen, cursor::Hide, cursor::MoveTo(0, result_top_index + self.settings.results + 1) ) .unwrap(); } fn results(&mut self, screen: &mut W) { let result_top_index = self.result_top_index(); queue!(screen, cursor::Hide, cursor::MoveTo(1, result_top_index)).unwrap(); let (width, height): (u16, u16) = terminal::size().unwrap(); let result_height = (height - RESULTS_TOP_INDEX) as usize + if self.is_screen_view_bottom() { 1 } else { 0 }; if !self.matches.is_empty() && self.selection > self.matches.len() - 1 { self.selection = self.matches.len() - 1; } let mut index = 0; let in_page = self.selection < result_height; let view_range = if in_page { let len = result_height.min(self.matches.len()); &self.matches[..len] } else { let offset = self.selection - result_height + 1; let len = (offset + result_height).min(self.matches.len()); &self.matches[offset..len] }; for command in view_range { let mut fg = if self.settings.lightmode { self.settings.colors.lightmode_colors.results_fg } else { self.settings.colors.darkmode_colors.results_fg }; let mut highlight = if self.settings.lightmode { self.settings.colors.lightmode_colors.results_hl } else { self.settings.colors.darkmode_colors.results_hl }; let mut bg = Color::Reset; if index == self.selection.min(result_height - 1) { if self.settings.lightmode { fg = self.settings.colors.lightmode_colors.results_selection_fg; bg = self.settings.colors.lightmode_colors.results_selection_bg; highlight = self.settings.colors.lightmode_colors.results_selection_hl; } else { fg = self.settings.colors.darkmode_colors.results_selection_fg; bg = self.settings.colors.darkmode_colors.results_selection_bg; highlight = self.settings.colors.darkmode_colors.results_selection_hl; } } let command_line_index = self.command_line_index(index as i16); queue!( screen, cursor::MoveTo(1, (command_line_index + result_top_index as i16) as u16), Clear(ClearType::CurrentLine), SetBackgroundColor(bg), SetForegroundColor(fg), Print(Interface::truncate_for_display( command, width, highlight, fg, self.debug )) ) .unwrap(); if let Some(last_run) = command.last_run { queue!( screen, cursor::MoveTo( width - 9, (command_line_index + result_top_index as i16) as u16 ) ) .unwrap(); let duration = &format_duration( Duration::minutes( Utc::now() .signed_duration_since(Utc.timestamp_opt(last_run, 0).unwrap()) .num_minutes(), ) .to_std() .unwrap(), ) .to_string() .split(' ') .take(2) .map(|s| { s.replace("years", "y") .replace("year", "y") .replace("months", "mo") .replace("month", "mo") .replace("days", "d") .replace("day", "d") .replace("hours", "h") .replace("hour", "h") .replace("minutes", "m") .replace("minute", "m") .replace("0s", "< 1m") }) .collect::>() .join(" "); let timing_color = if self.settings.lightmode { self.settings.colors.lightmode_colors.timing } else { self.settings.colors.darkmode_colors.timing }; queue!( screen, cursor::MoveTo( width - 9, (command_line_index + self.result_top_index() as i16) as u16 ), SetForegroundColor(timing_color), Print(format!("{duration:>9}")), SetForegroundColor(Color::Reset), SetBackgroundColor(Color::Reset) ) .unwrap(); } index += 1; } // Since we only clear by line instead of clearing the screen each update, // we need to clear all the lines that may have previously had a command for i in index..result_height { let command_line_index = self.command_line_index(i as i16); queue!( screen, cursor::MoveTo(1, (command_line_index + result_top_index as i16) as u16), Clear(ClearType::CurrentLine) ) .unwrap(); } } #[allow(unused)] fn debug>(&self, screen: &mut W, s: S) { queue!( screen, cursor::MoveTo(0, 0), Clear(ClearType::CurrentLine), Print(s.into()) ) .unwrap(); screen.flush().unwrap(); } fn move_selection(&mut self, direction: MoveSelection) { if self.is_screen_view_bottom() { match direction { MoveSelection::Up => { self.selection += 1; } MoveSelection::Down => { if self.selection > 0 { self.selection -= 1; } } } } else { match direction { MoveSelection::Up => { if self.selection > 0 { self.selection -= 1; } } MoveSelection::Down => { self.selection += 1; } } } } fn accept_selection(&mut self) { if !self.matches.is_empty() { self.input.set(&self.matches[self.selection].cmd); } } fn confirm(&mut self, confirmation: bool) { if confirmation && let MenuMode::ConfirmDelete = self.menu_mode { self.delete_selection(); } self.menu_mode = MenuMode::Normal; } fn delete_selection(&mut self) { if !self.matches.is_empty() { { let command = &self.matches[self.selection]; history_cleaner::clean(self.settings, self.history, &command.cmd); self.delete_requests.push(command.cmd.clone()); } self.build_cache_table(); self.refresh_matches(false); } } fn refresh_matches(&mut self, reset_selection: bool) { if reset_selection { self.selection = 0; } self.matches = self.history.find_matches( &self.input.command, self.settings.results as i16, self.settings.fuzzy, &self.result_sort, ); } fn switch_result_sort(&mut self) { match self.result_sort { ResultSort::Rank => self.result_sort = ResultSort::LastRun, ResultSort::LastRun => self.result_sort = ResultSort::Rank, } } fn switch_result_filter(&mut self) { self.result_filter = match self.result_filter { ResultFilter::Global => ResultFilter::CurrentDirectory, ResultFilter::CurrentDirectory => ResultFilter::Global, }; self.build_cache_table(); } fn select(&mut self) { let mut screen = stdout(); terminal::enable_raw_mode().unwrap(); queue!(screen, EnterAlternateScreen, Clear(ClearType::All)).unwrap(); self.refresh_matches(true); self.results(&mut screen); self.menubar(&mut screen); self.prompt(&mut screen); screen.flush().unwrap(); loop { let event = read().unwrap_or_else(|e| panic!("McFly error: failed to read input {:?}", &e)); self.debug_cursor(&mut screen); match self.menu_mode { MenuMode::Normal => { let early_out = match self.settings.key_scheme { KeyScheme::Emacs => self.select_with_emacs_key_scheme(event), KeyScheme::Vim => { if let Event::Key(key_event) = event { self.select_with_vim_key_scheme(key_event) } else { false } } }; if early_out { break; } } MenuMode::ConfirmDelete => { if let Event::Key(key_event) = event { match key_event { KeyEvent { modifiers: KeyModifiers::CONTROL, code: Char('c' | 'd' | 'g' | 'z' | 'r'), .. } => { self.run = false; self.input.clear(); break; } KeyEvent { code: Char('y' | 'Y'), .. } => { self.confirm(true); } KeyEvent { code: Char('n' | 'N') | KeyCode::Esc, .. } => { self.confirm(false); } _ => {} } } } } self.results(&mut screen); self.menubar(&mut screen); self.prompt(&mut screen); screen.flush().unwrap(); } queue!( screen, Clear(ClearType::All), cursor::Show, LeaveAlternateScreen ) .unwrap(); terminal::disable_raw_mode().unwrap(); } fn select_with_emacs_key_scheme(&mut self, event: Event) -> bool { match event { Event::Key(event) => self.handle_emacs_keyevent(event), Event::Paste(s) => { for i in s.chars() { self.input.insert(i); } self.refresh_matches(true); false } _ => false, } } fn handle_emacs_keyevent(&mut self, event: KeyEvent) -> bool { if event.kind != KeyEventKind::Press { return false; } match event { KeyEvent { code: KeyCode::Enter | Char('\r' | '\n'), .. } | KeyEvent { modifiers: KeyModifiers::CONTROL, code: Char('j'), .. } => { self.run = !self.settings.disable_run_command; self.accept_selection(); return true; } KeyEvent { code: KeyCode::Tab, .. } => { self.run = false; self.accept_selection(); return true; } KeyEvent { modifiers: KeyModifiers::CONTROL, code: Char('c' | 'g' | 'z' | 'r'), .. } | KeyEvent { code: KeyCode::Esc, .. } => { self.run = false; self.input.clear(); return true; } KeyEvent { modifiers: KeyModifiers::CONTROL, code, .. } => match code { Char('b') => self.input.move_cursor(Move::Backward), Char('f') => self.input.move_cursor(Move::Forward), Char('a') => self.input.move_cursor(Move::BOL), Char('e') => self.input.move_cursor(Move::EOL), Char('v') => self.debug = !self.debug, Char('k') => { self.input.delete(Move::EOL); self.refresh_matches(true); } Char('u') => { self.input.delete(Move::BOL); self.refresh_matches(true); } Char('w') => { self.input.delete(Move::BackwardWord); self.refresh_matches(true); } Char('p') => self.move_selection(MoveSelection::Up), Char('n') => self.move_selection(MoveSelection::Down), Char('h') => { self.input.delete(Move::Backward); self.refresh_matches(true); } Char('d') => { self.input.delete(Move::Forward); self.refresh_matches(true); } _ => {} }, KeyEvent { modifiers: KeyModifiers::ALT, code: Char('\x08' | '\x7f'), .. } => { self.input.delete(Move::BackwardWord); self.refresh_matches(true); } KeyEvent { modifiers: KeyModifiers::ALT, code, .. } => match code { Char('b') => self.input.move_cursor(Move::BackwardWord), Char('f') => self.input.move_cursor(Move::ForwardWord), Char('d') => { self.input.delete(Move::ForwardWord); self.refresh_matches(true); } _ => {} }, KeyEvent { code: KeyCode::Left, .. } => self.input.move_cursor(Move::Backward), KeyEvent { code: KeyCode::Right, .. } => self.input.move_cursor(Move::Forward), KeyEvent { code: KeyCode::Up | KeyCode::PageUp, .. } => self.move_selection(MoveSelection::Up), KeyEvent { code: KeyCode::Down | KeyCode::PageDown, .. } => self.move_selection(MoveSelection::Down), KeyEvent { code: KeyCode::Backspace, .. } => { self.input.delete(Move::Backward); self.refresh_matches(true); } KeyEvent { code: KeyCode::Delete, .. } => { self.input.delete(Move::Forward); self.refresh_matches(true); } KeyEvent { code: KeyCode::Home, .. } => self.input.move_cursor(Move::BOL), KeyEvent { code: KeyCode::End, .. } => self.input.move_cursor(Move::EOL), KeyEvent { code: Char(c), .. } => { self.input.insert(c); self.refresh_matches(true); } KeyEvent { code: KeyCode::F(1), .. } => { self.switch_result_sort(); self.refresh_matches(true); } KeyEvent { code: KeyCode::F(2), .. } => { if !self.matches.is_empty() { if self.settings.delete_without_confirm { self.delete_selection(); } else { self.menu_mode = MenuMode::ConfirmDelete; } } } KeyEvent { code: KeyCode::F(3), .. } => { self.switch_result_filter(); self.refresh_matches(true); } _ => {} } false } fn select_with_vim_key_scheme(&mut self, event: KeyEvent) -> bool { if event.kind != KeyEventKind::Press { return false; } if self.in_vim_insert_mode { match event { KeyEvent { code: KeyCode::Tab, .. } => { self.run = false; self.accept_selection(); return true; } KeyEvent { code: KeyCode::Enter, .. } | KeyEvent { modifiers: KeyModifiers::CONTROL, code: Char('j'), .. } => { self.run = !self.settings.disable_run_command; self.accept_selection(); return true; } KeyEvent { modifiers: KeyModifiers::CONTROL, code: Char('c' | 'g' | 'z' | 'r'), .. } => { self.run = false; self.input.clear(); return true; } KeyEvent { code: KeyCode::Left, .. } => self.input.move_cursor(Move::Backward), KeyEvent { code: KeyCode::Right, .. } => self.input.move_cursor(Move::Forward), KeyEvent { code: KeyCode::Up | KeyCode::PageUp, .. } | KeyEvent { modifiers: KeyModifiers::CONTROL, code: Char('u' | 'p'), .. } => self.move_selection(MoveSelection::Up), KeyEvent { code: KeyCode::Down | KeyCode::PageDown, .. } | KeyEvent { modifiers: KeyModifiers::CONTROL, code: Char('d' | 'n'), .. } => self.move_selection(MoveSelection::Down), KeyEvent { code: KeyCode::Esc, .. } => self.in_vim_insert_mode = false, KeyEvent { code: KeyCode::Backspace, .. } => { self.input.delete(Move::Backward); self.refresh_matches(true); } KeyEvent { code: KeyCode::Delete, .. } => { self.input.delete(Move::Forward); self.refresh_matches(true); } KeyEvent { code: KeyCode::Home, .. } => self.input.move_cursor(Move::BOL), KeyEvent { code: KeyCode::End, .. } => self.input.move_cursor(Move::EOL), KeyEvent { code: Char(c), .. } => { self.input.insert(c); self.refresh_matches(true); } KeyEvent { code: KeyCode::F(1), .. } => { self.switch_result_sort(); self.refresh_matches(true); } KeyEvent { code: KeyCode::F(2), .. } => { if !self.matches.is_empty() { if self.settings.delete_without_confirm { self.delete_selection(); } else { self.menu_mode = MenuMode::ConfirmDelete; } } } KeyEvent { code: KeyCode::F(3), .. } => { self.switch_result_filter(); self.refresh_matches(true); } _ => {} } } else { match event { KeyEvent { code: KeyCode::Tab, .. } => { self.run = false; self.accept_selection(); return true; } KeyEvent { code: KeyCode::Enter, .. } | KeyEvent { modifiers: KeyModifiers::CONTROL, code: Char('j'), .. } => { self.run = !self.settings.disable_run_command; self.accept_selection(); return true; } KeyEvent { modifiers: KeyModifiers::CONTROL, code: Char('c' | 'g' | 'z' | 'r'), .. } | KeyEvent { code: KeyCode::Esc, .. } => { self.run = false; self.input.clear(); return true; } KeyEvent { code: KeyCode::Left | Char('h'), .. } => self.input.move_cursor(Move::Backward), KeyEvent { code: KeyCode::Right | Char('l'), .. } => self.input.move_cursor(Move::Forward), KeyEvent { code: KeyCode::Up | KeyCode::PageUp | Char('k'), .. } | KeyEvent { modifiers: KeyModifiers::CONTROL, code: Char('u'), .. } => self.move_selection(MoveSelection::Up), KeyEvent { code: KeyCode::Down | KeyCode::PageDown | Char('j'), .. } | KeyEvent { modifiers: KeyModifiers::CONTROL, code: Char('d'), .. } => self.move_selection(MoveSelection::Down), KeyEvent { code: Char('b' | 'e'), .. } => self.input.move_cursor(Move::BackwardWord), KeyEvent { code: Char('w'), .. } => self.input.move_cursor(Move::ForwardWord), KeyEvent { code: Char('0' | '^'), .. } => self.input.move_cursor(Move::BOL), KeyEvent { code: Char('$'), .. } => self.input.move_cursor(Move::EOL), KeyEvent { code: Char('i' | 'a'), .. } => self.in_vim_insert_mode = true, KeyEvent { code: KeyCode::Backspace, .. } => { self.input.delete(Move::Backward); self.refresh_matches(true); } KeyEvent { code: KeyCode::Delete | Char('x'), .. } => { self.input.delete(Move::Forward); self.refresh_matches(true); } KeyEvent { code: KeyCode::Home, .. } => self.input.move_cursor(Move::BOL), KeyEvent { code: KeyCode::End, .. } => self.input.move_cursor(Move::EOL), KeyEvent { code: KeyCode::F(1), .. } => { self.switch_result_sort(); self.refresh_matches(true); } KeyEvent { code: KeyCode::F(2), .. } => { if !self.matches.is_empty() { if self.settings.delete_without_confirm { self.delete_selection(); } else { self.menu_mode = MenuMode::ConfirmDelete; } } } KeyEvent { code: KeyCode::F(3), .. } => { self.switch_result_filter(); self.refresh_matches(true); } _ => {} } } false } fn truncate_for_display( command: &Command, width: u16, highlight_color: Color, base_color: Color, debug: bool, ) -> String { let debug_space = if debug { 90 } else { 0 }; let max_grapheme_length = if width > debug_space { width - debug_space - 9 } else { 11 }; let mut out = FixedLengthGraphemeString::empty(max_grapheme_length); let mut match_indices = command.match_indices.iter().peekable(); for (i, c) in command.cmd.char_indices() { match match_indices.peek() { Some(&&j) if i == j => { let _ = match_indices.next(); execute!(out, SetForegroundColor(highlight_color)).unwrap(); out.push_grapheme_str(c); } _ => { execute!(out, SetForegroundColor(base_color)).unwrap(); out.push_grapheme_str(c); } } } if debug { out.max_grapheme_length += debug_space; out.push_grapheme_str(" "); execute!(out, SetForegroundColor(Color::Blue)).unwrap(); out.push_grapheme_str(format!("rnk: {:.*} ", 2, command.rank)); out.push_grapheme_str(format!("age: {:.*} ", 2, command.features.age_factor)); out.push_grapheme_str(format!("lng: {:.*} ", 2, command.features.length_factor)); out.push_grapheme_str(format!("ext: {:.*} ", 0, command.features.exit_factor)); out.push_grapheme_str(format!( "r_ext: {:.*} ", 0, command.features.recent_failure_factor )); out.push_grapheme_str(format!("dir: {:.*} ", 3, command.features.dir_factor)); out.push_grapheme_str(format!( "s_dir: {:.*} ", 3, command.features.selected_dir_factor )); out.push_grapheme_str(format!("ovlp: {:.*} ", 3, command.features.overlap_factor)); out.push_grapheme_str(format!( "i_ovlp: {:.*} ", 3, command.features.immediate_overlap_factor )); out.push_grapheme_str(format!( "occ: {:.*}", 2, command.features.occurrences_factor )); out.push_grapheme_str(format!( "s_occ: {:.*} ", 2, command.features.selected_occurrences_factor )); execute!(out, SetForegroundColor(base_color)).unwrap(); } out.string } fn result_top_index(&self) -> u16 { let (_width, height): (u16, u16) = terminal::size().unwrap(); if self.is_screen_view_bottom() { return height - RESULTS_TOP_INDEX; } RESULTS_TOP_INDEX } fn prompt_line_index(&self) -> u16 { let (_width, height): (u16, u16) = terminal::size().unwrap(); if self.is_screen_view_bottom() { return height - PROMPT_LINE_INDEX; } PROMPT_LINE_INDEX } fn info_line_index(&self) -> u16 { let (_width, height): (u16, u16) = terminal::size().unwrap(); if self.is_screen_view_bottom() { return height; } INFO_LINE_INDEX } fn command_line_index(&self, index: i16) -> i16 { if self.is_screen_view_bottom() { return -index; } index } fn is_screen_view_bottom(&self) -> bool { self.settings.interface_view == InterfaceView::Bottom } } // TODO: // Ctrl('X') + Ctrl('U') => undo // Ctrl('X') + Ctrl('G') => abort // Meta('c') => capitalize word // Meta('l') => downcase word // Meta('t') => transpose words // Meta('u') => upcase word // Meta('y') => yank pop // Ctrl('r') => reverse history search // Ctrl('s') => forward history search // Ctrl('t') => transpose characters // Ctrl('q') | Ctrl('v') => quoted insert // Ctrl('y') => yank // Ctrl('_') => undo ================================================ FILE: src/lib.rs ================================================ pub mod cli; pub mod command_input; pub mod dumper; pub mod fake_typer; pub mod fixed_length_grapheme_string; pub mod history; pub mod history_cleaner; pub mod init; pub mod interface; pub mod network; pub mod node; pub mod path_update_helpers; pub mod settings; pub mod shell_history; pub mod simplified_command; pub mod stats_generator; pub mod time; pub mod trainer; pub mod training_cache; pub mod training_sample_generator; ================================================ FILE: src/main.rs ================================================ use std::fs; use std::path::PathBuf; use std::time::{SystemTime, UNIX_EPOCH}; use mcfly::dumper::Dumper; use mcfly::fake_typer; use mcfly::history::History; use mcfly::init::Init; use mcfly::interface::Interface; use mcfly::settings::Mode; use mcfly::settings::Settings; use mcfly::shell_history; use mcfly::stats_generator::StatsGenerator; use mcfly::trainer::Trainer; fn handle_addition(settings: &Settings) { let history = History::load(settings.history_format); if history.should_add(&settings.command) { history.add( &settings.command, &settings.session_id, &settings.dir, &settings.when_run, settings.exit_code, &settings.old_dir, ); if let Some(append_to_histfile) = &settings.append_to_histfile { let histfile = PathBuf::from(append_to_histfile); let command = shell_history::HistoryCommand::new( &settings.command, settings.when_run.unwrap_or( SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_else(|err| panic!("McFly error: Time went backwards ({err})")) .as_secs() as i64, ), settings.history_format, ); shell_history::append_history_entry(&command, &histfile, settings.debug); } } } fn handle_search(settings: &Settings) { let history = History::load(settings.history_format); let result = Interface::new(settings, &history).display(); if let Some(cmd) = result.selection { if let Some(path) = &settings.output_selection { // Output selection results to a file. let mut out: String = String::new(); // First we say the desired mode, depending on the key pressed by the user - simply // displaying the selected command, or running it. if result.run { out.push_str("mode run\n"); } else { out.push_str("mode display\n"); } // Next, the desired commandline selected by the user. out.push_str("commandline "); out.push_str(&cmd); out.push('\n'); // Finally, any requests for deletion of commands from shell history, for cases where // shells need to handle this natively instead of through us editing HISTFILE/MCFLY_HISTFILE. for delete_request in result.delete_requests { out.push_str("delete "); out.push_str(&delete_request); out.push('\n'); } fs::write(path, &out) .unwrap_or_else(|err| panic!("McFly error: unable to write to {path}: {err}")); } else { fake_typer::use_tiocsti(&cmd); if result.run { fake_typer::use_tiocsti("\n"); } } } } fn handle_train(settings: &Settings) { let mut history = History::load(settings.history_format); Trainer::new(settings, &mut history).train(); } fn handle_move(settings: &Settings) { let history = History::load(settings.history_format); history.update_paths(&settings.old_dir.clone().unwrap(), &settings.dir, true); } fn handle_init(settings: &Settings) { Init::new(&settings.init_mode); } fn handle_dump(settings: &Settings) { let history = History::load(settings.history_format); Dumper::new(settings, &history).dump(); } fn handle_stats(settings: &Settings) { let history = History::load(settings.history_format); let stats = StatsGenerator::new(&history).generate_stats(settings); println!("{stats}"); } fn main() { let mut settings = Settings::parse_args(); settings.load_config(); match settings.mode { Mode::Add => { handle_addition(&settings); } Mode::Search => { handle_search(&settings); } Mode::Train => { handle_train(&settings); } Mode::Move => { handle_move(&settings); } Mode::Init => { handle_init(&settings); } Mode::Dump => { handle_dump(&settings); } Mode::Stats => handle_stats(&settings), } } ================================================ FILE: src/network.rs ================================================ #![allow(clippy::unreadable_literal)] use crate::history::Features; use crate::node::Node; use crate::training_sample_generator::TrainingSampleGenerator; use rand::Rng; #[derive(Debug, Copy, Clone)] pub struct Network { pub final_bias: f64, pub final_weights: [f64; 3], pub final_sum: f64, pub final_output: f64, pub hidden_nodes: [Node; 3], pub hidden_node_sums: [f64; 3], pub hidden_node_outputs: [f64; 3], } impl Default for Network { fn default() -> Network { Network { final_bias: -0.3829333755179377, final_weights: [0.44656858145177714, -1.9550439349609872, -2.963322601316632], final_sum: 0.0, final_output: 0.0, hidden_nodes: [ Node { offset: -0.878184962836099, age: -0.9045522440219468, length: 0.5406937685800283, exit: -0.3472765681766297, recent_failure: -0.05291342121445077, selected_dir: -0.35027519196134, dir: -0.2466069217936986, overlap: 0.4791784213482642, immediate_overlap: 0.5565797758340211, selected_occurrences: -0.3600203296209723, occurrences: 0.15694312742881805, }, Node { offset: -0.04362945902379799, age: -0.25381913331319716, length: 0.4238780143901607, exit: 0.21906785628210726, recent_failure: -0.9510136025685453, selected_dir: -0.04654084670567356, dir: -2.2858050301068693, overlap: -0.562274365705918, immediate_overlap: -0.47252489212451904, selected_occurrences: 0.2446391951417497, occurrences: -1.4846489581676605, }, Node { offset: -0.11992725490486622, age: 0.3759013420273308, length: 1.674601413922965, exit: -0.15529596916772864, recent_failure: -0.7819181782432957, selected_dir: -1.1890532332896768, dir: 0.34723729558743677, overlap: 0.09372412920642742, immediate_overlap: 0.393989158881144, selected_occurrences: -0.2383372126951215, occurrences: -2.196219880265691, }, ], hidden_node_sums: [0.0, 0.0, 0.0], hidden_node_outputs: [0.0, 0.0, 0.0], } } } impl Network { #[must_use] pub fn random() -> Network { let mut rng = rand::rng(); Network { final_bias: rng.random_range(-1.0..1.0), final_weights: [ rng.random_range(-1.0..1.0), rng.random_range(-1.0..1.0), rng.random_range(-1.0..1.0), ], hidden_nodes: [Node::random(), Node::random(), Node::random()], hidden_node_sums: [0.0, 0.0, 0.0], hidden_node_outputs: [0.0, 0.0, 0.0], final_sum: 0.0, final_output: 0.0, } } pub fn compute(&mut self, features: &Features) { self.final_sum = self.final_bias; for i in 0..self.hidden_nodes.len() { self.hidden_node_sums[i] = self.hidden_nodes[i].dot(features); self.hidden_node_outputs[i] = self.hidden_node_sums[i].tanh(); self.final_sum += self.hidden_node_outputs[i] * self.final_weights[i]; } self.final_output = self.final_sum.tanh(); } #[must_use] pub fn dot(&self, features: &Features) -> f64 { let mut network_output = self.final_bias; for (node, output_weight) in self.hidden_nodes.iter().zip(self.final_weights.iter()) { let node_output = node.output(features); network_output += node_output * output_weight; } network_output } #[must_use] pub fn output(&self, features: &Features) -> f64 { self.dot(features).tanh() } #[must_use] pub fn average_error(&self, generator: &TrainingSampleGenerator, records: usize) -> f64 { let mut error = 0.0; let mut samples = 0.0; generator.generate(Some(records), |features: &Features, correct: bool| { let target = if correct { 1.0 } else { -1.0 }; let output = self.output(features); error += 0.5 * (target - output).powi(2); samples += 1.0; }); error / samples } } ================================================ FILE: src/node.rs ================================================ use crate::history::Features; use rand::Rng; use std::f64; #[derive(Debug, Copy, Clone, Default)] pub struct Node { pub offset: f64, pub age: f64, pub length: f64, pub exit: f64, pub recent_failure: f64, pub selected_dir: f64, pub dir: f64, pub overlap: f64, pub immediate_overlap: f64, pub selected_occurrences: f64, pub occurrences: f64, } impl Node { #[must_use] pub fn random() -> Node { let mut rng = rand::rng(); Node { offset: rng.random_range(-1.0..1.0), age: rng.random_range(-1.0..1.0), length: rng.random_range(-1.0..1.0), exit: rng.random_range(-1.0..1.0), recent_failure: rng.random_range(-1.0..1.0), selected_dir: rng.random_range(-1.0..1.0), dir: rng.random_range(-1.0..1.0), overlap: rng.random_range(-1.0..1.0), immediate_overlap: rng.random_range(-1.0..1.0), selected_occurrences: rng.random_range(-1.0..1.0), occurrences: rng.random_range(-1.0..1.0), } } #[must_use] pub fn dot(&self, features: &Features) -> f64 { self.offset + features.age_factor * self.age + features.length_factor * self.length + features.exit_factor * self.exit + features.recent_failure_factor * self.recent_failure + features.selected_dir_factor * self.selected_dir + features.dir_factor * self.dir + features.overlap_factor * self.overlap + features.immediate_overlap_factor * self.immediate_overlap + features.selected_occurrences_factor * self.selected_occurrences + features.occurrences_factor * self.occurrences } #[must_use] pub fn output(&self, features: &Features) -> f64 { self.dot(features).tanh() } } #[cfg(test)] mod tests { use super::*; #[test] fn test_dot() { let node = Node { offset: 0.0, age: 0.0, length: 0.0, exit: 0.0, recent_failure: 0.0, selected_dir: 0.0, dir: 0.0, overlap: 0.0, immediate_overlap: 0.0, selected_occurrences: 0.0, occurrences: 0.0, }; let features = Features { age_factor: 1.0, length_factor: 1.0, exit_factor: 1.0, recent_failure_factor: 1.0, selected_dir_factor: 1.0, dir_factor: 1.0, overlap_factor: 1.0, immediate_overlap_factor: 1.0, selected_occurrences_factor: 1.0, occurrences_factor: 1.0, }; assert_eq!(node.dot(&features), 0.0); } } ================================================ FILE: src/path_update_helpers.rs ================================================ use crate::settings::pwd; use path_absolutize::Absolutize; use std::path::Path; use unicode_segmentation::UnicodeSegmentation; #[must_use] pub fn normalize_path(incoming_path: &str) -> String { let expanded_path = shellexpand::tilde(incoming_path).to_string(); Path::new(&expanded_path) .absolutize_from(pwd()) .unwrap() .to_str() .unwrap_or_else(|| panic!("McFly error: Path must be a valid UTF8 string")) .to_string() } #[must_use] pub fn parse_mv_command(command: &str) -> Vec { let mut in_double_quote = false; let mut in_single_quote = false; let mut escaped = false; let mut buffer = String::new(); let mut result: Vec = Vec::new(); for grapheme in command.graphemes(true) { match grapheme { "\\" => { escaped = true; } "\"" => { if escaped { escaped = false; buffer.push_str(grapheme); } else if in_double_quote { in_double_quote = false; if !buffer.is_empty() { result.push(buffer); } buffer = String::new(); } else if !in_single_quote { in_double_quote = true; } else { buffer.push_str(grapheme); } } "\'" => { if in_single_quote { in_single_quote = false; if !buffer.is_empty() { result.push(buffer); } buffer = String::new(); } else if !in_double_quote { in_single_quote = true; } else { buffer.push_str(grapheme); } escaped = false; } " " => { if in_double_quote || in_single_quote || escaped { buffer.push_str(grapheme); } else { if !buffer.is_empty() { result.push(buffer); } buffer = String::new(); } escaped = false; } _ => { buffer.push_str(grapheme); escaped = false; } } } if !buffer.is_empty() { result.push(buffer); } result .iter() .skip(1) .filter(|s| !s.starts_with('-')) .map(std::borrow::ToOwned::to_owned) .collect() } #[cfg(test)] mod tests { use super::{normalize_path, parse_mv_command}; use std::env; use std::path::PathBuf; #[test] #[cfg(not(windows))] fn normalize_path_works_absolute_paths() { assert_eq!(normalize_path("/foo/bar/baz"), String::from("/foo/bar/baz")); assert_eq!(normalize_path("/"), String::from("/")); assert_eq!(normalize_path("////"), String::from("/")); } #[test] #[cfg(not(windows))] fn normalize_path_works_with_tilda() { assert_eq!(normalize_path("~/"), env::var("HOME").unwrap()); assert_eq!( normalize_path("~/foo"), PathBuf::from(env::var("HOME").unwrap()) .join("foo") .to_string_lossy() ); } #[test] #[cfg(not(windows))] fn normalize_path_works_with_double_dots() { assert_eq!(normalize_path("/foo/bar/../baz"), String::from("/foo/baz")); assert_eq!(normalize_path("/foo/bar/../../baz"), String::from("/baz")); assert_eq!(normalize_path("/foo/bar/../../"), String::from("/")); assert_eq!(normalize_path("/foo/bar/../.."), String::from("/")); assert_eq!( normalize_path("~/foo/bar/../baz"), PathBuf::from(env::var("HOME").unwrap()) .join("foo/baz") .to_string_lossy() ); assert_eq!(normalize_path("~/foo/bar/../.."), env::var("HOME").unwrap()); } #[cfg(windows)] fn windows_home_path() -> String { PathBuf::from(env::var("HOMEDRIVE").unwrap()) .join(env::var("HOMEPATH").unwrap()) .to_str() .unwrap() .to_string() } #[test] #[cfg(windows)] fn normalize_path_works_absolute_paths() { assert_eq!( normalize_path("C:\\foo\\bar\\baz"), String::from("C:\\foo\\bar\\baz") ); assert_eq!(normalize_path("C:\\"), String::from("C:\\")); assert_eq!(normalize_path("C:\\\\\\\\"), String::from("C:\\")); } #[test] #[cfg(windows)] fn normalize_path_works_with_tilda() { assert_eq!(normalize_path("~\\"), windows_home_path()); assert_eq!( normalize_path("~\\foo"), PathBuf::from(windows_home_path()) .join("foo") .to_string_lossy() ); } #[test] #[cfg(windows)] fn normalize_path_works_with_double_dots() { assert_eq!( normalize_path("C:\\foo\\bar\\..\\baz"), String::from("C:\\foo\\baz") ); assert_eq!( normalize_path("C:\\foo\\bar\\..\\..\\baz"), String::from("C:\\baz") ); assert_eq!( normalize_path("C:\\foo\\bar\\..\\..\\"), String::from("C:\\") ); assert_eq!(normalize_path("C:\\foo\\bar\\..\\.."), String::from("C:\\")); assert_eq!( normalize_path("~\\foo\\bar\\..\\baz"), PathBuf::from(windows_home_path()) .join("foo\\baz") .to_string_lossy() ); assert_eq!(normalize_path("~\\foo\\bar\\..\\.."), windows_home_path()); } #[test] fn parse_mv_command_works_in_the_basic_case() { assert_eq!( parse_mv_command("mv foo bar"), vec!["foo".to_string(), "bar".to_string()] ); } #[test] fn parse_mv_command_works_with_options() { assert_eq!( parse_mv_command("mv -v foo bar"), vec!["foo".to_string(), "bar".to_string()] ); } #[test] fn parse_mv_command_works_with_escaped_strings() { assert_eq!( parse_mv_command("mv \"foo baz\" 'bar bing'"), vec!["foo baz".to_string(), "bar bing".to_string()] ); assert_eq!( parse_mv_command("mv -v \"foo\" 'bar'"), vec!["foo".to_string(), "bar".to_string()] ); } #[test] fn parse_mv_command_works_with_escaping() { assert_eq!( parse_mv_command("mv \\foo bar"), vec!["foo".to_string(), "bar".to_string()] ); assert_eq!( parse_mv_command("mv foo\\ bar bing"), vec!["foo bar".to_string(), "bing".to_string()] ); assert_eq!( parse_mv_command("mv \"foo\\ bar\" bing"), vec!["foo bar".to_string(), "bing".to_string()] ); assert_eq!( parse_mv_command("mv \"'foo\\' bar\" bing"), vec!["'foo' bar".to_string(), "bing".to_string()] ); assert_eq!( parse_mv_command("mv \"\\\"foo\" bar"), vec!["\"foo".to_string(), "bar".to_string()] ); } } ================================================ FILE: src/settings.rs ================================================ use crate::cli::{Cli, DumpFormat, SortOrder, SubCommand}; use crate::shell_history; use crate::time::parse_timestamp; use clap::Parser; use config::Source; use config::Value; use crossterm::style::Color; use directories_next::{ProjectDirs, UserDirs}; use regex::Regex; use std::collections::HashMap; use std::env; use std::path::PathBuf; use std::str::FromStr; use std::time::SystemTime; use std::time::UNIX_EPOCH; #[derive(Debug)] pub enum Mode { Add, Search, Train, Move, Init, Dump, Stats, } #[derive(Debug)] pub enum KeyScheme { Emacs, Vim, } #[derive(Debug)] pub enum InitMode { Bash, Zsh, Fish, Powershell, } #[derive(Debug, PartialEq, Eq)] pub enum InterfaceView { Top, Bottom, } #[derive(Debug, Clone, PartialEq, Eq)] pub enum ResultSort { Rank, LastRun, } #[derive(Debug, Clone, PartialEq, Eq)] pub enum ResultFilter { Global, CurrentDirectory, } #[derive(Debug, Clone, Copy)] pub enum HistoryFormat { /// bash format - commands in plain text, one per line, with multi-line commands joined. /// HISTTIMEFORMAT is assumed to be empty. Bash, /// zsh format - commands in plain text, with multiline commands on multiple lines. /// `McFly` does not currently handle joining these lines; they're treated as separate commands. /// If --zsh-extended-history was given, `extended_history` will be true, and we'll strip the /// timestamp from the beginning of each command. Zsh { extended_history: bool }, /// fish's pseudo-yaml, with commands stored as 'cmd' with multiple lines joined into one with /// '\n', and with timestamps stored as 'when'. ('paths' is ignored.) /// (Some discussion of changing format: https://github.com/fish-shell/fish-shell/pull/6493) Fish, } /// Time range, it can be: /// - `..` /// - `since..before` /// - `since..` /// - `..before` #[derive(Debug, Clone, Default)] pub struct TimeRange { pub since: Option, pub before: Option, } #[derive(Debug)] pub struct Colors { pub menubar_bg: Color, pub menubar_fg: Color, pub darkmode_colors: DarkModeColors, pub lightmode_colors: LightModeColors, } #[derive(Debug)] pub struct DarkModeColors { pub prompt: Color, pub timing: Color, pub results_fg: Color, pub results_bg: Color, pub results_hl: Color, pub results_selection_fg: Color, pub results_selection_bg: Color, pub results_selection_hl: Color, } #[derive(Debug)] pub struct LightModeColors { pub prompt: Color, pub timing: Color, pub results_fg: Color, pub results_bg: Color, pub results_hl: Color, pub results_selection_fg: Color, pub results_selection_bg: Color, pub results_selection_hl: Color, } #[derive(Debug)] pub struct Settings { pub mode: Mode, pub debug: bool, pub fuzzy: i16, pub session_id: String, pub mcfly_history: PathBuf, pub output_selection: Option, pub command: String, pub dir: String, pub results: u16, pub when_run: Option, pub exit_code: Option, pub old_dir: Option, pub append_to_histfile: Option, pub refresh_training_cache: bool, pub lightmode: bool, pub key_scheme: KeyScheme, pub history_format: HistoryFormat, pub limit: Option, pub skip_environment_check: bool, pub init_mode: InitMode, pub delete_without_confirm: bool, pub interface_view: InterfaceView, pub result_sort: ResultSort, pub result_filter: ResultFilter, pub disable_menu: bool, pub prompt: String, pub disable_run_command: bool, pub time_range: TimeRange, pub sort_order: SortOrder, pub pattern: Option, pub dump_format: DumpFormat, pub colors: Colors, pub stats_min_cmd_length: i16, pub stats_cmds: i16, pub stats_dirs: i16, pub stats_global_commands_to_ignore: i16, pub stats_only_dir: Option, } impl Default for Settings { fn default() -> Settings { Settings { mode: Mode::Add, output_selection: None, command: String::new(), session_id: String::new(), mcfly_history: PathBuf::new(), dir: String::new(), results: 30, when_run: None, exit_code: None, old_dir: None, refresh_training_cache: false, append_to_histfile: None, debug: false, fuzzy: 0, lightmode: false, key_scheme: KeyScheme::Emacs, history_format: HistoryFormat::Bash, limit: None, skip_environment_check: false, init_mode: InitMode::Bash, delete_without_confirm: false, interface_view: InterfaceView::Top, result_sort: ResultSort::Rank, result_filter: ResultFilter::Global, disable_menu: false, prompt: String::from("$"), disable_run_command: false, time_range: TimeRange::default(), sort_order: SortOrder::default(), pattern: None, dump_format: DumpFormat::default(), colors: Colors { menubar_bg: Color::Blue, menubar_fg: Color::White, darkmode_colors: DarkModeColors { prompt: Color::White, timing: Color::Blue, results_fg: Color::White, results_bg: Color::Black, results_hl: Color::Blue, results_selection_fg: Color::Black, results_selection_bg: Color::DarkGrey, results_selection_hl: Color::DarkGreen, }, lightmode_colors: LightModeColors { prompt: Color::Black, timing: Color::DarkBlue, results_fg: Color::Black, results_bg: Color::White, results_hl: Color::Blue, results_selection_fg: Color::White, results_selection_bg: Color::DarkGrey, results_selection_hl: Color::Grey, }, }, stats_min_cmd_length: 0, stats_cmds: 10, stats_dirs: 0, stats_global_commands_to_ignore: 10, stats_only_dir: None, } } } impl Settings { pub fn parse_args() -> Settings { let cli = Cli::parse(); let mut settings = Settings { skip_environment_check: cli.is_init(), ..Default::default() }; settings.debug = cli.debug || is_env_var_truthy("MCFLY_DEBUG"); settings.limit = env::var("MCFLY_HISTORY_LIMIT") .ok() .and_then(|o| o.parse::().ok()); settings.interface_view = match env::var("MCFLY_INTERFACE_VIEW") { Ok(val) => match val.to_uppercase().as_str() { "TOP" => InterfaceView::Top, "BOTTOM" => InterfaceView::Bottom, _ => InterfaceView::Top, }, _ => InterfaceView::Top, }; settings.result_sort = match env::var("MCFLY_RESULTS_SORT") { Ok(val) => match val.to_uppercase().as_str() { "RANK" => ResultSort::Rank, "LAST_RUN" => ResultSort::LastRun, _ => ResultSort::Rank, }, _ => ResultSort::Rank, }; settings.result_filter = match env::var("MCFLY_RESULTS_FILTER") { Ok(val) => match val.to_uppercase().as_str() { "GLOBAL" => ResultFilter::Global, "CURRENT_DIRECTORY" => ResultFilter::CurrentDirectory, _ => ResultFilter::Global, }, _ => ResultFilter::Global, }; settings.session_id = cli.session_id.unwrap_or_else(|| env::var("MCFLY_SESSION_ID") .unwrap_or_else(|err| { if !settings.skip_environment_check { panic!( "McFly error: Please ensure that MCFLY_SESSION_ID contains a random session ID ({err})" ) } else { String::new() } } ) ); settings.mcfly_history = cli.mcfly_history.unwrap_or_else(|| { { env::var("MCFLY_HISTORY").unwrap_or_else(|err| { if !settings.skip_environment_check { panic!("McFly error: Please ensure that MCFLY_HISTORY is set ({err})") } else { String::new() } }) } .into() }); { use crate::cli::HistoryFormat::{Bash, Fish, Zsh, ZshExtended}; settings.history_format = match cli.history_format { Bash => HistoryFormat::Bash, Zsh => HistoryFormat::Zsh { extended_history: false, }, ZshExtended => HistoryFormat::Zsh { extended_history: true, }, Fish => HistoryFormat::Fish, }; } match cli.command { SubCommand::Add { command, exit, append_to_histfile, when, directory, old_directory, } => { settings.mode = Mode::Add; settings.when_run = when.or_else(|| { Some( SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_else(|err| { panic!("McFly error: Time went backwards ({err})") }) .as_secs() as i64, ) }); settings.append_to_histfile = append_to_histfile; settings.exit_code = exit; settings.dir = directory.unwrap_or_else(pwd); settings.old_dir = old_directory.or_else(|| env::var("OLDPWD").ok()); if !command.is_empty() { settings.command = command.join(" "); } else { settings.command = shell_history::last_history_line( &settings.mcfly_history, settings.history_format, ) .unwrap_or_default(); } // CD shows PWD as the resulting directory, but we want it from the source directory. if settings.command.starts_with("cd ") || settings.command.starts_with("pushd ") || settings.command.starts_with("j ") { settings.dir = settings.old_dir.clone().unwrap_or(settings.dir); } } SubCommand::Search { command, directory, results, fuzzy, delete_without_confirm, output_selection, } => { settings.mode = Mode::Search; settings.dir = directory.unwrap_or_else(pwd); if let Ok(results) = env::var("MCFLY_RESULTS") && let Ok(results) = u16::from_str(&results) { settings.results = results; } if let Some(results) = results { settings.results = results; } if let Ok(fuzzy) = env::var("MCFLY_FUZZY") { if let Ok(fuzzy) = i16::from_str(&fuzzy) { settings.fuzzy = fuzzy; } else if fuzzy.to_lowercase() != "false" { settings.fuzzy = 2; } } if let Some(fuzzy) = fuzzy { settings.fuzzy = fuzzy; } settings.delete_without_confirm = delete_without_confirm || is_env_var_truthy("MCFLY_DELETE_WITHOUT_CONFIRM"); settings.output_selection = output_selection; if !command.is_empty() { settings.command = command.join(" "); } else { settings.command = shell_history::last_history_line( &settings.mcfly_history, settings.history_format, ) .unwrap_or_default() .trim_start_matches("#mcfly: ") .trim_start_matches("#mcfly:") .to_string(); shell_history::delete_last_history_entry_if_search( &settings.mcfly_history, settings.history_format, settings.debug, ); } } SubCommand::Train { refresh_cache } => { settings.mode = Mode::Train; settings.refresh_training_cache = refresh_cache; } SubCommand::Move { old_dir_path, new_dir_path, } => { settings.mode = Mode::Move; settings.old_dir = Some(old_dir_path); settings.dir = new_dir_path; } SubCommand::Init { shell } => { settings.mode = Mode::Init; use crate::cli::InitMode::{Bash, Fish, Powershell, Zsh}; settings.init_mode = match shell { Bash => InitMode::Bash, Zsh => InitMode::Zsh, Fish => InitMode::Fish, Powershell => InitMode::Powershell, }; } SubCommand::Dump { since, before, sort, regex, format, } => { settings.mode = Mode::Dump; settings.time_range.since = since.as_ref().map(|s| parse_timestamp(s)); settings.time_range.before = before.as_ref().map(|s| parse_timestamp(s)); settings.sort_order = sort; settings.pattern = regex; settings.dump_format = format; } SubCommand::Stats { min_cmd_length, cmds, dirs, global_commands_to_ignore, only_dir, } => { settings.mode = Mode::Stats; settings.stats_min_cmd_length = min_cmd_length; settings.stats_cmds = cmds; settings.stats_dirs = dirs; settings.stats_global_commands_to_ignore = global_commands_to_ignore; settings.stats_only_dir = only_dir; } } settings.lightmode = is_env_var_truthy("MCFLY_LIGHT"); settings.disable_menu = is_env_var_truthy("MCFLY_DISABLE_MENU"); settings.disable_run_command = is_env_var_truthy("MCFLY_DISABLE_RUN_COMMAND"); settings.key_scheme = match env::var("MCFLY_KEY_SCHEME").as_ref().map(String::as_ref) { Ok("vim") => KeyScheme::Vim, _ => KeyScheme::Emacs, }; if let Ok(prompt) = env::var("MCFLY_PROMPT") && prompt.chars().count() == 1 { settings.prompt = prompt; } settings } pub fn load_config(&mut self) { let config_path = Settings::mcfly_config_path(); if config_path.exists() { let config = config::File::from(config_path); if let Ok(config_map) = config.collect() { self.merge_config(config_map); } }; } pub fn merge_config(&mut self, config_map: HashMap) { let color_config = config_map.get("colors"); let menubar_config = color_config .and_then(|v| v.clone().into_table().ok()) .and_then(|v| v.get("menubar").and_then(|v| v.clone().into_table().ok())); if let Some(menubar_config) = menubar_config { if let Some(menubar_bg) = menubar_config .get("bg") .and_then(|v| v.clone().into_string().ok()) .and_then(|v| Color::from_str(v.as_str()).ok()) { self.colors.menubar_bg = menubar_bg; } if let Some(menubar_fg) = menubar_config .get("fg") .and_then(|v| v.clone().into_string().ok()) .and_then(|v| Color::from_str(v.as_str()).ok()) { self.colors.menubar_fg = menubar_fg; } } let darkmode_config = color_config .and_then(|v| v.clone().into_table().ok()) .and_then(|v| v.get("darkmode").and_then(|v| v.clone().into_table().ok())); if let Some(darkmode_config) = darkmode_config { if let Some(prompt) = darkmode_config .get("prompt") .and_then(|v| v.clone().into_string().ok()) .and_then(|v| Color::from_str(v.as_str()).ok()) { self.colors.darkmode_colors.prompt = prompt; } if let Some(timing) = darkmode_config .get("timing") .and_then(|v| v.clone().into_string().ok()) .and_then(|v| Color::from_str(v.as_str()).ok()) { self.colors.darkmode_colors.timing = timing; } if let Some(results_fg) = darkmode_config .get("results_fg") .and_then(|v| v.clone().into_string().ok()) .and_then(|v| Color::from_str(v.as_str()).ok()) { self.colors.darkmode_colors.results_fg = results_fg; } if let Some(results_bg) = darkmode_config .get("results_bg") .and_then(|v| v.clone().into_string().ok()) .and_then(|v| Color::from_str(v.as_str()).ok()) { self.colors.darkmode_colors.results_bg = results_bg; } if let Some(results_hl) = darkmode_config .get("results_hl") .and_then(|v| v.clone().into_string().ok()) .and_then(|v| Color::from_str(v.as_str()).ok()) { self.colors.darkmode_colors.results_hl = results_hl; } if let Some(results_selection_fg) = darkmode_config .get("results_selection_fg") .and_then(|v| v.clone().into_string().ok()) .and_then(|v| Color::from_str(v.as_str()).ok()) { self.colors.darkmode_colors.results_selection_fg = results_selection_fg; } if let Some(results_selection_bg) = darkmode_config .get("results_selection_bg") .and_then(|v| v.clone().into_string().ok()) .and_then(|v| Color::from_str(v.as_str()).ok()) { self.colors.darkmode_colors.results_selection_bg = results_selection_bg; } if let Some(results_selection_hl) = darkmode_config .get("results_selection_hl") .and_then(|v| v.clone().into_string().ok()) .and_then(|v| Color::from_str(v.as_str()).ok()) { self.colors.darkmode_colors.results_selection_hl = results_selection_hl; } } let lightmode_config = color_config .and_then(|v| v.clone().into_table().ok()) .and_then(|v| v.get("lightmode").and_then(|v| v.clone().into_table().ok())); if let Some(lightmode_config) = lightmode_config { if let Some(prompt) = lightmode_config .get("prompt") .and_then(|v| v.clone().into_string().ok()) .and_then(|v| Color::from_str(v.as_str()).ok()) { self.colors.lightmode_colors.prompt = prompt; } if let Some(timing) = lightmode_config .get("timing") .and_then(|v| v.clone().into_string().ok()) .and_then(|v| Color::from_str(v.as_str()).ok()) { self.colors.lightmode_colors.timing = timing; } if let Some(results_fg) = lightmode_config .get("results_fg") .and_then(|v| v.clone().into_string().ok()) .and_then(|v| Color::from_str(v.as_str()).ok()) { self.colors.lightmode_colors.results_fg = results_fg; } if let Some(results_bg) = lightmode_config .get("results_bg") .and_then(|v| v.clone().into_string().ok()) .and_then(|v| Color::from_str(v.as_str()).ok()) { self.colors.lightmode_colors.results_bg = results_bg; } if let Some(results_hl) = lightmode_config .get("results_hl") .and_then(|v| v.clone().into_string().ok()) .and_then(|v| Color::from_str(v.as_str()).ok()) { self.colors.lightmode_colors.results_hl = results_hl; } if let Some(results_selection_fg) = lightmode_config .get("results_selection_fg") .and_then(|v| v.clone().into_string().ok()) .and_then(|v| Color::from_str(v.as_str()).ok()) { self.colors.lightmode_colors.results_selection_fg = results_selection_fg; } if let Some(results_selection_bg) = lightmode_config .get("results_selection_bg") .and_then(|v| v.clone().into_string().ok()) .and_then(|v| Color::from_str(v.as_str()).ok()) { self.colors.lightmode_colors.results_selection_bg = results_selection_bg; } if let Some(results_selection_hl) = lightmode_config .get("results_selection_hl") .and_then(|v| v.clone().into_string().ok()) .and_then(|v| Color::from_str(v.as_str()).ok()) { self.colors.lightmode_colors.results_selection_hl = results_selection_hl; } } } // Use ~/.mcfly only if it already exists, otherwise create 'mcfly' folder in XDG_CACHE_DIR #[must_use] pub fn mcfly_training_cache_path() -> PathBuf { let cache_dir = Settings::mcfly_xdg_dir().cache_dir().to_path_buf(); Settings::mcfly_base_path(cache_dir).join(PathBuf::from("training-cache.v1.csv")) } // Use ~/.mcfly only if it already exists, otherwise create 'mcfly' folder in XDG_DATA_DIR #[must_use] pub fn mcfly_db_path() -> PathBuf { let data_dir = Settings::mcfly_xdg_dir().data_dir().to_path_buf(); if data_dir.exists() { return Settings::mcfly_base_path(data_dir).join(PathBuf::from("history.db")); }; let data_local_dir = Settings::mcfly_xdg_dir().data_local_dir().to_path_buf(); Settings::mcfly_base_path(data_local_dir).join(PathBuf::from("history.db")) } // Use ~/.mcfly only if it already exists, otherwise create 'mcfly' folder in XDG_DATA_DIR #[must_use] pub fn mcfly_config_path() -> PathBuf { let data_dir = Settings::mcfly_xdg_dir().data_dir().to_path_buf(); Settings::mcfly_base_path(data_dir).join(PathBuf::from("config.toml")) } fn mcfly_xdg_dir() -> ProjectDirs { ProjectDirs::from("", "", "McFly").unwrap() } fn mcfly_base_path(base_dir: PathBuf) -> PathBuf { Settings::mcfly_dir_in_home().unwrap_or(base_dir) } fn mcfly_dir_in_home() -> Option { let user_dirs_file = UserDirs::new() .unwrap() .home_dir() .join(PathBuf::from(".mcfly")); user_dirs_file.exists().then_some(user_dirs_file) } } #[cfg(not(windows))] #[must_use] pub fn pwd() -> String { env::var("PWD") .unwrap_or_else(|err| panic!("McFly error: Unable to determine current directory ({err})")) } #[cfg(windows)] pub fn pwd() -> String { env::current_dir() .unwrap_or_else(|err| { panic!( "McFly error: Unable to determine current directory ({})", err ) }) .display() .to_string() } fn is_env_var_truthy(name: &str) -> bool { match env::var(name) { Ok(val) => { val != "F" && val != "f" && val != "false" && val != "False" && val != "FALSE" && val != "0" } Err(_) => false, } } impl TimeRange { /// Determine the range is full (`..`) #[inline] #[must_use] pub fn is_full(&self) -> bool { self.since.is_none() && self.before.is_none() } } ================================================ FILE: src/shell_history.rs ================================================ use crate::settings::HistoryFormat; use regex::Regex; use std::env; use std::fmt; use std::fs; use std::fs::File; use std::fs::OpenOptions; use std::io::Read; use std::io::Write; use std::path::Path; use std::path::PathBuf; use std::str::FromStr; use std::time::{SystemTime, UNIX_EPOCH}; fn read_ignoring_utf_errors(path: &Path) -> String { let mut f = File::open(path).unwrap_or_else(|_| panic!("McFly error: {:?} file not found", &path)); let mut buffer = Vec::new(); f.read_to_end(&mut buffer) .unwrap_or_else(|_| panic!("McFly error: Unable to read from {:?}", &path)); String::from_utf8_lossy(&buffer).to_string() } // Zsh uses a meta char (0x83) to signify that the previous character should be ^ 32. fn read_and_unmetafy(path: &Path) -> String { let mut f = File::open(path).unwrap_or_else(|_| panic!("McFly error: {:?} file not found", &path)); let mut buffer = Vec::new(); f.read_to_end(&mut buffer) .unwrap_or_else(|_| panic!("McFly error: Unable to read from {:?}", &path)); for index in (0..buffer.len()).rev() { if buffer[index] == 0x83 { buffer.remove(index); buffer[index] ^= 32; } } String::from_utf8_lossy(&buffer).to_string() } #[allow(clippy::if_same_then_else)] fn has_leading_timestamp(line: &str) -> bool { let mut matched_chars = 0; for (index, c) in line.chars().enumerate() { if index == 0 && c == '#' { matched_chars += 1; } else if index > 0 && index < 11 && (c.is_ascii_digit()) { matched_chars += 1; } else if index > 11 { break; } } matched_chars == 11 } #[must_use] pub fn history_file_path() -> PathBuf { let path = PathBuf::from( env::var("HISTFILE") .or_else(|_| env::var("MCFLY_HISTFILE")) .unwrap_or_else(|err| { panic!( "McFly error: Please ensure HISTFILE or MCFLY_HISTFILE is set for your shell ({err})" ) }), ); fs::canonicalize(path).unwrap_or_else(|err| { panic!("McFly error: The contents of $HISTFILE/$MCFLY_HISTFILE appears invalid ({err})") }) } /// Represents each entry in a history file. #[derive(Debug)] pub struct HistoryCommand { /// The user's command. pub command: String, /// When the command was run, in seconds since Unix epoch. pub when: i64, /// The format of the file, so we can write the record back out. pub format: HistoryFormat, } impl HistoryCommand { pub fn new(command: S, when: i64, format: HistoryFormat) -> Self where S: Into, { Self { command: command.into(), when, format, } } } impl fmt::Display for HistoryCommand { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self.format { HistoryFormat::Bash => write!(f, "{}", self.command), HistoryFormat::Zsh { extended_history } => { if extended_history { write!(f, ": {}:0;{}", self.when, self.command) } else { write!(f, "{}", self.command) } } HistoryFormat::Fish => writeln!(f, "- cmd: {}\n when: {}", self.command, self.when), } } } #[must_use] pub fn full_history(path: &Path, history_format: HistoryFormat) -> Vec { match history_format { HistoryFormat::Bash => { let history_contents = read_ignoring_utf_errors(path); let zsh_timestamp_and_duration_regex = Regex::new(r"^: [0-9]+:[0-9]+;").unwrap(); let when = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_else(|err| panic!("McFly error: Time went backwards ({err})")) .as_secs() as i64; history_contents .split('\n') .filter(|line| !has_leading_timestamp(line) && !line.is_empty()) .map(|line| zsh_timestamp_and_duration_regex.replace(line, "")) .map(|line| HistoryCommand::new(line, when, history_format)) .collect() } HistoryFormat::Zsh { .. } => { let history_contents = read_and_unmetafy(path); let zsh_timestamp_and_duration_regex = Regex::new(r"^: [0-9]+:[0-9]+;").unwrap(); let when = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_else(|err| panic!("McFly error: Time went backwards ({err})")) .as_secs() as i64; history_contents .split('\n') .filter(|line| !has_leading_timestamp(line) && !line.is_empty()) .map(|line| zsh_timestamp_and_duration_regex.replace(line, "")) .map(|line| HistoryCommand::new(line, when, history_format)) .collect() } HistoryFormat::Fish => { // Fish history format is not technically YAML. This is a naive parser of the format, // only caring about command strings (which are always on one line, with embedded // newlines) and timestamps, ignoring the 'paths' field. let mut commands = Vec::new(); let history_contents = read_ignoring_utf_errors(path); // Store command strings, and add them as HistoryCommand when we see the timestamp. let mut command = None; for line in history_contents.split('\n') { if line.starts_with("- cmd: ") { command = Some(line.split_at(7).1); } else if line.starts_with(" when: ") { let when_str = line.split_at(8).1; let when = i64::from_str(when_str).unwrap_or_else(|e| panic!("McFly error: fish history '{}' has 'when' that's not a valid i64 ({}) - {}", path.display(), when_str, e)); // Remove (take) the command string, restarting our state machine. commands.push(HistoryCommand::new( command.take().unwrap_or_else(|| panic!("McFly error: invalid fish history file '{}', found 'when' without 'cmd' ({})", path.display(), when)), when, history_format, )); } // ignore other lines, like 'paths' lists } commands } } } #[must_use] pub fn last_history_line(path: &Path, history_format: HistoryFormat) -> Option { // Could switch to https://github.com/mikeycgto/rev_lines full_history(path, history_format) .last() .map(|s| s.command.trim().to_string()) } pub fn delete_last_history_entry_if_search( path: &Path, history_format: HistoryFormat, debug: bool, ) { let mut commands = full_history(path, history_format); if !commands.is_empty() && commands[commands.len() - 1].command.is_empty() { commands.pop(); } let starts_with_mcfly = Regex::new(r"^(: [0-9]+:[0-9]+;)?#mcfly:").unwrap(); if commands.is_empty() || !starts_with_mcfly.is_match(&commands[commands.len() - 1].command) { return; // Abort if empty or the last line isn't a comment. } if debug { println!( "McFly: Removed from file '{}': {:?}", path.display(), commands.pop() ); } else { commands.pop(); } if !commands.is_empty() && has_leading_timestamp(&commands[commands.len() - 1].command) { commands.pop(); } let lines = commands .into_iter() .map(|cmd| cmd.to_string()) // Newline at end of file. .chain(Some(String::new())) .collect::>(); fs::write(path, lines.join("\n")) .unwrap_or_else(|_| panic!("McFly error: Unable to update {:?}", &path)); } pub fn delete_lines(path: &Path, history_format: HistoryFormat, command: &str) { let commands = full_history(path, history_format); let zsh_timestamp_and_duration_regex = Regex::new(r"^: [0-9]+:[0-9]+;").unwrap(); let lines = commands .into_iter() .filter(|cmd| !command.eq(&zsh_timestamp_and_duration_regex.replace(&cmd.command, ""))) .map(|cmd| cmd.to_string()) // Newline at end of file. .chain(Some(String::new())) .collect::>(); fs::write(path, lines.join("\n")) .unwrap_or_else(|_| panic!("McFly error: Unable to update {:?}", &path)); } pub fn append_history_entry(command: &HistoryCommand, path: &Path, debug: bool) { let mut file = OpenOptions::new() .append(true) .open(path) .unwrap_or_else(|err| { panic!( "McFly error: please make sure the specified --append-to-histfile file ({path:?}) exists ({err})" ) }); if debug { println!("McFly: Appended to file '{:?}': {}", &path, command); } if let Err(e) = writeln!(file, "{command}") { eprintln!("Couldn't append to file '{}': {}", path.display(), e); } } #[cfg(test)] mod tests { use super::has_leading_timestamp; #[test] fn has_leading_timestamp_works() { assert!(!has_leading_timestamp("abc")); assert!(!has_leading_timestamp("#abc")); assert!(!has_leading_timestamp("#123456")); assert!(has_leading_timestamp("#1234567890")); assert!(!has_leading_timestamp("#123456789")); assert!(!has_leading_timestamp("# 1234567890")); assert!(!has_leading_timestamp("1234567890")); assert!(!has_leading_timestamp("hello 1234567890")); } } ================================================ FILE: src/simplified_command.rs ================================================ use unicode_segmentation::UnicodeSegmentation; const TRUNCATE_TO_N_TOKENS: u16 = 2; #[derive(Debug)] pub struct SimplifiedCommand { pub original: String, pub result: String, pub truncate: bool, } #[allow(clippy::collapsible_if)] /// The goal of `SimplifiedCommand` is to produce a reduced approximation of the given command for template matching. It may /// not produce an exact simplification. (For example, it does not handle deeply nested escaping, and it drops escape characters.) /// Possible enhancements: /// - Sort and expand command line options. /// - Check to see if unknown strings represent valid local paths in the directory where the command was run. impl SimplifiedCommand { pub fn new>(command: S, truncate: bool) -> SimplifiedCommand { let mut simplified_command = SimplifiedCommand { original: command.into(), result: String::new(), truncate, }; simplified_command.simplify(); simplified_command } fn simplify(&mut self) { let mut in_double_quote = false; let mut in_single_quote = false; let mut escaped = false; let mut buffer = String::new(); let mut tokens = 0; for grapheme in self.original.graphemes(true) { match grapheme { "\\" => { escaped = true; } "\"" => { if escaped { escaped = false; } else if in_double_quote { in_double_quote = false; self.result.push_str("QUOTED"); } else if !in_single_quote { in_double_quote = true; } } "\'" => { if in_single_quote { in_single_quote = false; self.result.push_str("QUOTED"); } else if !in_double_quote { in_single_quote = true; } escaped = false; } " " | ":" | "," => { if !in_double_quote && !in_single_quote { if self.truncate && grapheme.eq(" ") { tokens += 1; if tokens >= TRUNCATE_TO_N_TOKENS { break; } } if !self.result.is_empty() && buffer.contains('/') { self.result.push_str("PATH"); } else { self.result.push_str(&buffer); } self.result.push_str(grapheme); buffer.clear(); } } _ => { if !in_double_quote && !in_single_quote { buffer.push_str(grapheme); } escaped = false; } } } if !self.result.is_empty() && buffer.contains('/') { self.result.push_str("PATH"); } else { self.result.push_str(&buffer); } } } #[cfg(test)] mod tests { use super::SimplifiedCommand; #[test] fn it_works_for_simple_commands() { let simplified_command = SimplifiedCommand::new("git push", false); assert_eq!(simplified_command.result, "git push"); let simplified_command = SimplifiedCommand::new("git pull", false); assert_eq!(simplified_command.result, "git pull"); let simplified_command = SimplifiedCommand::new("rake db:test:prepare", false); assert_eq!(simplified_command.result, "rake db:test:prepare"); } #[test] fn it_simplifies_simple_quoted_strings() { let simplified_command = SimplifiedCommand::new("git ci -m 'my commit message'", false); assert_eq!(simplified_command.result, "git ci -m QUOTED"); let simplified_command = SimplifiedCommand::new("git ci -m 'my \"commit\" message'", false); assert_eq!(simplified_command.result, "git ci -m QUOTED"); let simplified_command = SimplifiedCommand::new("git ci -m \"my commit message\"", false); assert_eq!(simplified_command.result, "git ci -m QUOTED"); let simplified_command = SimplifiedCommand::new("git ci -m \"my 'commit' message\"", false); assert_eq!(simplified_command.result, "git ci -m QUOTED"); } #[test] fn it_handles_one_level_of_quote_escaping() { let simplified_command = SimplifiedCommand::new("git ci -m \"my \\\"commit\\\" mes\\\\sage\"", false); assert_eq!(simplified_command.result, "git ci -m QUOTED"); } #[test] fn it_ignores_escaping_otherwise() { let simplified_command = SimplifiedCommand::new("git ci -m \\foo\\", false); assert_eq!(simplified_command.result, "git ci -m foo"); } #[test] fn it_simplifies_obvious_paths() { let simplified_command = SimplifiedCommand::new("ls /", false); assert_eq!(simplified_command.result, "ls PATH"); let simplified_command = SimplifiedCommand::new("cd ../foo", false); assert_eq!(simplified_command.result, "cd PATH"); let simplified_command = SimplifiedCommand::new("cd foo/", false); assert_eq!(simplified_command.result, "cd PATH"); let simplified_command = SimplifiedCommand::new("cd ..", false); assert_eq!(simplified_command.result, "cd .."); let simplified_command = SimplifiedCommand::new("cd foo/bar/baz", false); assert_eq!(simplified_command.result, "cd PATH"); let simplified_command = SimplifiedCommand::new("command path/1/2/3:/foo/bar", false); assert_eq!(simplified_command.result, "command PATH:PATH"); let simplified_command = SimplifiedCommand::new("blah --input foo/bar/baz --output blarg", false); assert_eq!( simplified_command.result, "blah --input PATH --output blarg" ); let simplified_command = SimplifiedCommand::new("cd ambiguous", false); assert_eq!(simplified_command.result, "cd ambiguous"); } #[test] fn it_ignores_leading_paths() { let simplified_command = SimplifiedCommand::new("../ls /", false); assert_eq!(simplified_command.result, "../ls PATH"); let simplified_command = SimplifiedCommand::new("./cd ../foo", false); assert_eq!(simplified_command.result, "./cd PATH"); let simplified_command = SimplifiedCommand::new("/bin/cd foo/", false); assert_eq!(simplified_command.result, "/bin/cd PATH"); } #[test] fn it_truncates_after_simplification() { let simplified_command = SimplifiedCommand::new("../ls /", true); assert_eq!(simplified_command.result, "../ls PATH"); let simplified_command = SimplifiedCommand::new("blah --input foo/bar/baz --output blarg", true); assert_eq!(simplified_command.result, "blah --input"); let simplified_command = SimplifiedCommand::new("git ci -m \"my \\\"commit\\\" mes\\\\sage\"", true); assert_eq!(simplified_command.result, "git ci"); } // #[test] // fn it_sorts_and_expands_command_line_arguments() { // let simplified_command = SimplifiedCommand::new("ls -t 2 -lah --foo bar --baz=bing"); // assert_eq!(simplified_command.result, "ls -a --baz=bing --foo bar -h -l -t 2"); // // let simplified_command = SimplifiedCommand::new("ls -l --foo bar -a -h --bazz=bing -t 2"); // assert_eq!(simplified_command.result, "ls -a --baz=bing --foo bar -h -l -t 2"); // } } ================================================ FILE: src/stats_generator.rs ================================================ use std::cmp::min; use std::collections::HashMap; use serde::Serialize; use crate::history::History; use crate::settings::Settings; #[derive(Debug)] pub struct StatsGenerator<'a> { history: &'a History, } #[derive(Debug, Clone, Serialize)] struct StatItem { cmd_tpl: String, count: i64, dir: Option, } impl<'a> StatsGenerator<'a> { #[must_use] pub fn generate_stats(&self, settings: &Settings) -> String { let mut lines = String::new(); let count_history = Self::count_commands_from_db_history(self, &None); if count_history == 0 { return "No history found in the database".to_string(); } lines.push_str("📊 Quick stats:\n"); if let Some(stats_only_dir) = &settings.stats_only_dir { lines.push_str( format!( " - your history database contains {:?} items total and {:?} in {:?}\n", count_history, Self::count_commands_from_db_history(self, &settings.stats_only_dir), stats_only_dir ) .as_mut_str(), ); } else { lines.push_str( format!(" - your history database contains {count_history:?} items\n") .as_mut_str(), ); } let most_used_commands = self.most_used_commands( settings.stats_cmds, settings.stats_min_cmd_length, settings.stats_dirs, settings.stats_global_commands_to_ignore, &settings.stats_only_dir, ); lines.push_str(&Self::generate_command_stats( self, settings.stats_cmds, most_used_commands, )); lines } fn generate_command_stats(&self, cmds: i16, stats: Vec) -> String { let mut lines = String::new(); let mut directory_map: HashMap, Vec<&StatItem>> = HashMap::new(); // Group stats by directory for item in &stats { directory_map .entry(item.dir.clone()) .or_default() .push(item); } for (dir, items) in &directory_map { if let Some(dir_name) = dir { lines.push_str(&format!( " - top {:?} matching commands in directory {:?}, sorted by occurrence:\n", min(cmds, items.len() as i16), dir_name )); } else { lines.push_str(&format!( " - top {:?} matching commands, sorted by occurrence:\n", min(cmds, items.len() as i16) )); } for item in &items[..min(cmds as usize, items.len())] { lines.push_str(&format!(" {} ({})\n", item.cmd_tpl, item.count)); } } if lines.contains("QUOTED") || lines.contains("PATH") { lines.push_str(" - (QUOTED and PATH indicate portions of a command that were removed for grouping)\n"); } lines } fn most_used_commands( &self, cmds: i16, min_cmd_length: i16, dirs: i16, global_commands_to_ignore: i16, only_dir: &Option, ) -> Vec { if dirs > 0 || only_dir.is_some() { let query = " WITH DirectoryCounts AS ( SELECT dir, COUNT(*) AS cmd_count FROM commands WHERE length(cmd_tpl) >= :min_cmd_length AND (:dir_filter_off OR dir = :only_dir) GROUP BY dir ORDER BY cmd_count DESC LIMIT MAX(1, :dirs) ), TopGlobalCommands AS ( SELECT cmd_tpl, COUNT(*) AS cmd_occurrence FROM commands WHERE length(cmd_tpl) >= :min_cmd_length GROUP BY cmd_tpl ORDER BY cmd_occurrence DESC LIMIT :global_commands_to_ignore ), TopCommands AS ( SELECT dir, cmd_tpl, COUNT(*) AS cmd_occurrence, ROW_NUMBER() OVER (PARTITION BY dir ORDER BY COUNT(*) DESC) AS row_num FROM commands WHERE dir IN (SELECT dir FROM DirectoryCounts) AND cmd_tpl NOT IN (SELECT cmd_tpl FROM TopGlobalCommands) AND length(cmd_tpl) >= :min_cmd_length GROUP BY dir, cmd_tpl ) SELECT dir, cmd_tpl, cmd_occurrence FROM TopCommands WHERE row_num <= :cmds ORDER BY dir, cmd_occurrence DESC "; self.history.run_query( query, &[ (":dir_filter_off", &only_dir.is_none()), (":only_dir", &only_dir.as_ref().unwrap_or(&String::new())), (":min_cmd_length", &min_cmd_length.to_owned()), (":cmds", &cmds.to_owned()), ( ":global_commands_to_ignore", &global_commands_to_ignore.to_owned(), ), (":dirs", &dirs.to_owned()), ], |row| { Ok(StatItem { dir: Some(row.get(0)?), cmd_tpl: row.get(1)?, count: row.get(2)?, }) }, ) } else { let query = " SELECT cmd_tpl, COUNT(1) AS n FROM commands WHERE length(cmd_tpl) >= :min_cmd_length GROUP BY 1 ORDER BY 2 DESC LIMIT :cmds "; self.history.run_query( query, &[ (":min_cmd_length", &min_cmd_length.to_owned()), (":cmds", &cmds.to_owned()), ], |row| { Ok(StatItem { cmd_tpl: row.get(0)?, count: row.get(1)?, dir: None, }) }, ) } } #[inline] pub fn new(history: &'a History) -> Self { Self { history } } fn count_commands_from_db_history(&self, dir: &Option) -> i32 { struct Count { count: i32, } let vec = self.history.run_query( "SELECT count(1) AS n FROM commands WHERE (:dir_filter_off OR dir = :directory)", &[ (":dir_filter_off", &dir.is_none()), (":directory", &dir.as_ref().unwrap_or(&String::new())), ], |row| Ok(Count { count: row.get(0)? }), ); vec.first().unwrap().count } } ================================================ FILE: src/time.rs ================================================ use chrono::{DateTime, Local, TimeZone}; #[must_use] pub fn parse_timestamp(s: &str) -> i64 { chrono_systemd_time::parse_timestamp_tz(s, Local) .unwrap_or_else(|err| panic!("McFly error: Failed to parse timestamp ({err})")) .latest() .timestamp() } #[inline] #[must_use] pub fn to_datetime(timestamp: i64) -> String { let utc = DateTime::from_timestamp(timestamp, 0).unwrap(); Local.from_utc_datetime(&utc.naive_utc()).to_rfc3339() } ================================================ FILE: src/trainer.rs ================================================ use crate::history::Features; use crate::history::History; use crate::network::Network; use crate::node::Node; use crate::settings::Settings; use crate::training_sample_generator::TrainingSampleGenerator; #[derive(Debug)] pub struct Trainer<'a> { settings: &'a Settings, history: &'a mut History, } impl<'a> Trainer<'a> { pub fn new(settings: &'a Settings, history: &'a mut History) -> Trainer<'a> { Trainer { settings, history } } pub fn train(&mut self) { let lr = 0.000_005; let momentum = 0.0; let batch_size = 1000; let plateau_threshold = 3000; let generator = TrainingSampleGenerator::new(self.settings, self.history); println!( "Evaluating error rate on current {:#?}", self.history.network ); let mut best_overall_network = self.history.network; let mut best_overall_error = self .history .network .average_error(&generator, batch_size * 10); println!("Current network error rate is {best_overall_error}"); loop { let mut best_restart_network = Network::random(); let mut best_restart_error = 10000.0; let mut cycles_since_best_restart_error = 0; let mut network = Network::random(); let mut node_increments = [Node::default(), Node::default(), Node::default()]; let mut output_increments = [0.0, 0.0, 0.0, 0.0]; loop { let mut batch_error = 0.0; let mut batch_samples = 0.0; // Two node network example: // (Note: we currently are using a three node version, s_0 to s_2 and o_0 to o_2.) // // b_1 // \ // f_1 --- s_1 -- o_1 // \ / \ // x b_3 -- s_3 -> o_3 -> e // / \ / // f_2 --- s_2 -- o_2 // / // b_2 // // Error (e) = 0.5(t - o_3)^2 // Final output (o_3) = tanh(s_3) // Final sum (s_3) = b_3 + w3_1*o_1 + w3_2*o_2 // Hidden node 1 output (o_1) = tanh(s_1) // Hidden node 1 sum (s_1) = b_1 + w1_1*f_1 + w1_2*f_2 // Hidden node 2 output (o_2) = tanh(s_2) // Hidden node 2 sum (s_2) = b_2 + w2_1*f_1 + w2_2*f_2 // Derivative of error with respect to o_3 (d_e/d_o_3 0.5(t - o_3)^2): -(t - o_3) // Derivative of o_3 respect to s_3 (d_o_3/d_s_3 tanh(s_3)): 1.0 - tanh(s_3)^2 // Derivative of s_3 with respect to weight w3_1 (d_s_3/d_w3_1 bias + w3_1*o_1 + w3_2*o_2): o_1 // Derivative of error with respect to weight w3_1 (d_e/d_o_3 * d_o_3/d_s_3 * d_s_3/d_w3_1): -(t - o_3) * (1 - tanh(s_3)^2) * o_1 // Derivative of s_3 with respect to o_1 (d_s_3/d_o_1 b_3 + w3_1*o_1 + w3_2*o_2): w3_1 // Derivative of o_1 with respect to s_1 (d_o_1/d_s_1): 1.0 - tanh(s_1)^2 // Derivative of s_1 with respect to weight w1_1 (d_s_1/d_w1_1): f_1 // Full derivation of o_3: tanh(b_3 + w3_1*tanh(b_1 + w1_1*f_1 + w1_2*f_2) + w3_2*tanh(b_2 + w2_1*f_1 + w2_2*f_2)) // Full derivation of s_3: b_3 + w3_1*tanh(b_1 + w1_1*f_1 + w1_2*f_2) + w3_2*tanh(b_2 + w2_1*f_1 + w2_2*f_2) // Full error derivation: 0.5(t - tanh(b_3 + w3_1*tanh(b_1 + w1_1*f_1 + w1_2*f_2) + w3_2*tanh(b_2 + w2_1*f_1 + w2_2*f_2)))^2 // Full derivative for o_1: -(t - o_3) * (1.0 - tanh(s_3)^2) * w3_1 // Full derivative for w1_1: -(t - o_3) * (1.0 - tanh(s_3)^2) * w3_1 * (1.0 - tanh(s_1)^2) * f_1 // Checked: https://www.wolframcloud.com/objects/617707c2-5016-4fb3-b73d-bd688b884967 generator.generate(Some(batch_size), |features: &Features, correct: bool| { let target = if correct { 1.0 } else { -1.0 }; network.compute(features); let error = 0.5 * (target - network.final_output).powi(2); batch_error += error; batch_samples += 1.0; let d_e_d_o_3 = -(target - network.final_output); let d_o_3_d_s_3 = 1.0 - network.final_sum.tanh().powi(2); // Output bias output_increments[0] = momentum * output_increments[0] + lr * d_e_d_o_3 * d_o_3_d_s_3 * 1.0; // Final sum node 1 output weight output_increments[1] = momentum * output_increments[1] + lr * d_e_d_o_3 * d_o_3_d_s_3 * network.hidden_node_outputs[0]; // Final sum node 2 output weight output_increments[2] = momentum * output_increments[2] + lr * d_e_d_o_3 * d_o_3_d_s_3 * network.hidden_node_outputs[1]; // Final sum node 3 output weight output_increments[3] = momentum * output_increments[3] + lr * d_e_d_o_3 * d_o_3_d_s_3 * network.hidden_node_outputs[2]; let d_s_3_d_o_0 = network.final_weights[0]; let d_s_3_d_o_1 = network.final_weights[1]; let d_s_3_d_o_2 = network.final_weights[2]; let d_o_0_d_s_0 = 1.0 - network.hidden_node_sums[0].tanh().powi(2); let d_o_1_d_s_1 = 1.0 - network.hidden_node_sums[1].tanh().powi(2); let d_o_2_d_s_2 = 1.0 - network.hidden_node_sums[2].tanh().powi(2); let d_e_d_s_0 = d_e_d_o_3 * d_o_3_d_s_3 * d_s_3_d_o_0 * d_o_0_d_s_0; let d_e_d_s_1 = d_e_d_o_3 * d_o_3_d_s_3 * d_s_3_d_o_1 * d_o_1_d_s_1; let d_e_d_s_2 = d_e_d_o_3 * d_o_3_d_s_3 * d_s_3_d_o_2 * d_o_2_d_s_2; node_increments[0].offset = momentum * node_increments[0].offset + lr * d_e_d_s_0 * 1.0; node_increments[0].age = momentum * node_increments[0].age + lr * d_e_d_s_0 * features.age_factor; node_increments[0].length = momentum * node_increments[0].length + lr * d_e_d_s_0 * features.length_factor; node_increments[0].exit = momentum * node_increments[0].exit + lr * d_e_d_s_0 * features.exit_factor; node_increments[0].recent_failure = momentum * node_increments[0].recent_failure + lr * d_e_d_s_0 * features.recent_failure_factor; node_increments[0].selected_dir = momentum * node_increments[0].selected_dir + lr * d_e_d_s_0 * features.selected_dir_factor; node_increments[0].dir = momentum * node_increments[0].dir + lr * d_e_d_s_0 * features.dir_factor; node_increments[0].overlap = momentum * node_increments[0].overlap + lr * d_e_d_s_0 * features.overlap_factor; node_increments[0].immediate_overlap = momentum * node_increments[0].immediate_overlap + lr * d_e_d_s_0 * features.immediate_overlap_factor; node_increments[0].selected_occurrences = momentum * node_increments[0].selected_occurrences + lr * d_e_d_s_0 * features.selected_occurrences_factor; node_increments[0].occurrences = momentum * node_increments[0].occurrences + lr * d_e_d_s_0 * features.occurrences_factor; node_increments[1].offset = momentum * node_increments[1].offset + lr * d_e_d_s_1 * 1.0; node_increments[1].age = momentum * node_increments[1].age + lr * d_e_d_s_1 * features.age_factor; node_increments[1].length = momentum * node_increments[1].length + lr * d_e_d_s_1 * features.length_factor; node_increments[1].exit = momentum * node_increments[1].exit + lr * d_e_d_s_1 * features.exit_factor; node_increments[1].recent_failure = momentum * node_increments[1].recent_failure + lr * d_e_d_s_1 * features.recent_failure_factor; node_increments[1].selected_dir = momentum * node_increments[1].selected_dir + lr * d_e_d_s_1 * features.selected_dir_factor; node_increments[1].dir = momentum * node_increments[1].dir + lr * d_e_d_s_1 * features.dir_factor; node_increments[1].overlap = momentum * node_increments[1].overlap + lr * d_e_d_s_1 * features.overlap_factor; node_increments[1].immediate_overlap = momentum * node_increments[1].immediate_overlap + lr * d_e_d_s_1 * features.immediate_overlap_factor; node_increments[1].selected_occurrences = momentum * node_increments[1].selected_occurrences + lr * d_e_d_s_1 * features.selected_occurrences_factor; node_increments[1].occurrences = momentum * node_increments[1].occurrences + lr * d_e_d_s_1 * features.occurrences_factor; node_increments[2].offset = momentum * node_increments[2].offset + lr * d_e_d_s_2 * 1.0; node_increments[2].age = momentum * node_increments[2].age + lr * d_e_d_s_2 * features.age_factor; node_increments[2].length = momentum * node_increments[2].length + lr * d_e_d_s_2 * features.length_factor; node_increments[2].exit = momentum * node_increments[2].exit + lr * d_e_d_s_2 * features.exit_factor; node_increments[2].recent_failure = momentum * node_increments[2].recent_failure + lr * d_e_d_s_2 * features.recent_failure_factor; node_increments[2].selected_dir = momentum * node_increments[2].selected_dir + lr * d_e_d_s_2 * features.selected_dir_factor; node_increments[2].dir = momentum * node_increments[2].dir + lr * d_e_d_s_2 * features.dir_factor; node_increments[2].overlap = momentum * node_increments[2].overlap + lr * d_e_d_s_2 * features.overlap_factor; node_increments[2].immediate_overlap = momentum * node_increments[2].immediate_overlap + lr * d_e_d_s_2 * features.immediate_overlap_factor; node_increments[2].selected_occurrences = momentum * node_increments[2].selected_occurrences + lr * d_e_d_s_2 * features.selected_occurrences_factor; node_increments[2].occurrences = momentum * node_increments[2].occurrences + lr * d_e_d_s_2 * features.occurrences_factor; let node0 = network.hidden_nodes[0]; let node1 = network.hidden_nodes[1]; let node2 = network.hidden_nodes[2]; network = Network { hidden_nodes: [ Node { offset: node0.offset - node_increments[0].offset, age: node0.age - node_increments[0].age, length: node0.length - node_increments[0].length, exit: node0.exit - node_increments[0].exit, recent_failure: node0.recent_failure - node_increments[0].recent_failure, selected_dir: node0.selected_dir - node_increments[0].selected_dir, dir: node0.dir - node_increments[0].dir, overlap: node0.overlap - node_increments[0].overlap, immediate_overlap: node0.immediate_overlap - node_increments[0].immediate_overlap, selected_occurrences: node0.selected_occurrences - node_increments[0].selected_occurrences, occurrences: node0.occurrences - node_increments[0].occurrences, }, Node { offset: node1.offset - node_increments[1].offset, age: node1.age - node_increments[1].age, length: node1.length - node_increments[1].length, exit: node1.exit - node_increments[1].exit, recent_failure: node1.recent_failure - node_increments[1].recent_failure, selected_dir: node1.selected_dir - node_increments[1].selected_dir, dir: node1.dir - node_increments[1].dir, overlap: node1.overlap - node_increments[1].overlap, immediate_overlap: node1.immediate_overlap - node_increments[1].immediate_overlap, selected_occurrences: node1.selected_occurrences - node_increments[1].selected_occurrences, occurrences: node1.occurrences - node_increments[1].occurrences, }, Node { offset: node2.offset - node_increments[2].offset, age: node2.age - node_increments[2].age, length: node2.length - node_increments[2].length, exit: node2.exit - node_increments[2].exit, recent_failure: node2.recent_failure - node_increments[2].recent_failure, selected_dir: node2.selected_dir - node_increments[2].selected_dir, dir: node2.dir - node_increments[2].dir, overlap: node2.overlap - node_increments[2].overlap, immediate_overlap: node2.immediate_overlap - node_increments[2].immediate_overlap, selected_occurrences: node2.selected_occurrences - node_increments[2].selected_occurrences, occurrences: node2.occurrences - node_increments[2].occurrences, }, ], hidden_node_sums: [0.0, 0.0, 0.0], hidden_node_outputs: [0.0, 0.0, 0.0], final_bias: network.final_bias - output_increments[0], final_weights: [ network.final_weights[0] - output_increments[1], network.final_weights[1] - output_increments[2], network.final_weights[2] - output_increments[3], ], final_sum: 0.0, final_output: 0.0, }; }); if batch_error / batch_samples < best_restart_error { best_restart_error = batch_error / batch_samples; best_restart_network = network; cycles_since_best_restart_error = 0; } else { cycles_since_best_restart_error += 1; if cycles_since_best_restart_error > plateau_threshold { println!("Plateaued at {}.", batch_error / batch_samples); if best_restart_error < best_overall_error { best_overall_error = best_restart_error; best_overall_network = best_restart_network; println!( "New best overall for {best_overall_network:#?} with error {best_overall_error} (new best)" ); } else { println!( "Best overall remains {best_overall_network:#?} with error {best_overall_error} (old)" ); } break; } } // println!("Error of {} (vs {} {} ago)", batch_error / batch_samples, best_restart_error, cycles_since_best_restart_error); } } } } ================================================ FILE: src/training_cache.rs ================================================ use crate::history::Features; use csv::Reader; use csv::Writer; use std::fs::File; use std::path::Path; pub fn write(data_set: &[(Features, bool)], cache_path: &Path) { let mut writer = Writer::from_path(cache_path) .unwrap_or_else(|err| panic!("McFly error: Expected to be able to write a CSV ({err})")); output_header(&mut writer); for (features, correct) in data_set { output_row(&mut writer, features, *correct); } } #[must_use] pub fn read(cache_path: &Path) -> Vec<(Features, bool)> { let mut data_set: Vec<(Features, bool)> = Vec::new(); let mut reader = Reader::from_path(cache_path) .unwrap_or_else(|err| panic!("McFly error: Expected to be able to read from CSV ({err})")); for result in reader.records() { let record = result.unwrap_or_else(|err| { panic!("McFly error: Expected to be able to unwrap cached result ({err})") }); let features = Features { age_factor: record[0].parse().unwrap(), length_factor: record[1].parse().unwrap(), exit_factor: record[2].parse().unwrap(), recent_failure_factor: record[3].parse().unwrap(), selected_dir_factor: record[4].parse().unwrap(), dir_factor: record[5].parse().unwrap(), overlap_factor: record[6].parse().unwrap(), immediate_overlap_factor: record[7].parse().unwrap(), selected_occurrences_factor: record[8].parse().unwrap(), occurrences_factor: record[9].parse().unwrap(), }; data_set.push((features, record[10].eq("t"))); } data_set } fn output_header(writer: &mut Writer) { writer .write_record([ "age_factor", "length_factor", "exit_factor", "recent_failure_factor", "selected_dir_factor", "dir_factor", "overlap_factor", "immediate_overlap_factor", "selected_occurrences_factor", "occurrences_factor", "correct", ]) .unwrap_or_else(|err| panic!("McFly error: Expected to write to CSV ({err})")); writer .flush() .unwrap_or_else(|err| panic!("McFly error: Expected to flush CSV ({err})")); } fn output_row(writer: &mut Writer, features: &Features, correct: bool) { writer .write_record(&[ format!("{}", features.age_factor), format!("{}", features.length_factor), format!("{}", features.exit_factor), format!("{}", features.recent_failure_factor), format!("{}", features.selected_dir_factor), format!("{}", features.dir_factor), format!("{}", features.overlap_factor), format!("{}", features.immediate_overlap_factor), format!("{}", features.selected_occurrences_factor), format!("{}", features.occurrences_factor), if correct { String::from("t") } else { String::from("f") }, ]) .unwrap_or_else(|err| panic!("McFly error: Expected to write to CSV ({err})")); writer .flush() .unwrap_or_else(|err| panic!("McFly error: Expected to flush CSV ({err})")); } ================================================ FILE: src/training_sample_generator.rs ================================================ use crate::history::Command; use crate::history::Features; use crate::history::History; use crate::settings::{ResultFilter, Settings}; use crate::training_cache; use rand::seq::IteratorRandom; use std::fs; #[derive(Debug)] pub struct TrainingSampleGenerator { data_set: Vec<(Features, bool)>, } impl TrainingSampleGenerator { pub fn new(settings: &Settings, history: &History) -> TrainingSampleGenerator { let cache_path = Settings::mcfly_training_cache_path(); let data_set = if settings.refresh_training_cache || !cache_path.exists() { let ds = TrainingSampleGenerator::generate_data_set(history); let mcfly_cache_dir = cache_path.parent().unwrap(); fs::create_dir_all(mcfly_cache_dir) .unwrap_or_else(|_| panic!("Unable to create {mcfly_cache_dir:?}")); training_cache::write(&ds, &cache_path); ds } else { training_cache::read(&cache_path) }; TrainingSampleGenerator { data_set } } pub fn generate_data_set(history: &History) -> Vec<(Features, bool)> { let mut data_set: Vec<(Features, bool)> = Vec::new(); let commands = history.commands(&None, -1, 0, true); let mut positive_examples = 0; let mut negative_examples = 0; println!("Generating training set for {} commands", commands.len()); for (i, command) in commands.iter().enumerate() { if command.dir.is_none() || command.exit_code.is_none() || command.when_run.is_none() { continue; } if command.cmd.is_empty() { continue; } if i % 100 == 0 { println!("Done with {i}"); } // Setup the cache for the time this command was recorded. // Unwrap is safe here because we check command.dir.is_none() above. history.build_cache_table( &command.dir.clone().unwrap(), &ResultFilter::Global, &Some(command.session_id.clone()), None, command.when_run, command.when_run, None, ); // Load the entire match set. let results = history.find_matches("", -1, 0, &crate::settings::ResultSort::Rank); // Get the features for this command at the time it was logged. if positive_examples <= negative_examples && let Some(our_command_index) = results.iter().position(|c| c.cmd.eq(&command.cmd)) { let what_should_have_been_first = &results[our_command_index]; data_set.push((what_should_have_been_first.features.clone(), true)); positive_examples += 1; } if negative_examples <= positive_examples { let mut rng = rand::rng(); // Get the features for another command that isn't the correct one. if let Some(random_command) = &results .iter() .filter(|c| !c.cmd.eq(&command.cmd)) .collect::>() .iter() .choose(&mut rng) { data_set.push((random_command.features.clone(), false)); negative_examples += 1; } } } println!("Done!"); data_set } pub fn generate(&self, records: Option, mut handler: F) where F: FnMut(&Features, bool), { let mut positive_examples = 0; let mut negative_examples = 0; let records = records.unwrap_or(self.data_set.len()); let mut rng = rand::rng(); loop { if let Some((features, correct)) = &self.data_set.iter().choose(&mut rng) { if *correct && positive_examples <= negative_examples { handler(features, *correct); positive_examples += 1; } else if !*correct && negative_examples <= positive_examples { handler(features, *correct); negative_examples += 1; } } if positive_examples + negative_examples >= records { break; } } } }