[
  {
    "path": ".cargo/audit.toml",
    "content": "[advisories]\nignore = [\n  # This is a vuln on RSA. RSA is in our lockfile, but not in cargo-tree. \n  # It is a issue with sqlx/cargo, and does not affect Atuin.\n  # See:\n  # - https://github.com/launchbadge/sqlx/issues/3211\n  # - https://github.com/rust-lang/cargo/issues/10801\n  \"RUSTSEC-2023-0071\"\n]\n"
  },
  {
    "path": ".codespellrc",
    "content": "[codespell]\n# Ref: https://github.com/codespell-project/codespell#using-a-config-file\nskip = .git*,*.lock,.codespellrc,CODE_OF_CONDUCT.md,CONTRIBUTORS\ncheck-hidden = true\n# ignore-regex = \nignore-words-list = crate,ratatui,inbetween,iterm,fo,brunch\n\n"
  },
  {
    "path": ".dockerignore",
    "content": "./target\nDockerfile\n"
  },
  {
    "path": ".gitattributes",
    "content": "*.sh eol=lf\n*.nix eol=lf\n*.zsh eol=lf\n\n*.sql eol=lf\n"
  },
  {
    "path": ".github/DISCUSSION_TEMPLATE/support.yml",
    "content": "body:\n  - type: input\n    attributes:\n      label: Operating System\n      description: What operating system are you using?\n      placeholder: \"Example: macOS Big Sur\"\n    validations:\n      required: true\n\n  - type: input\n    attributes:\n      label: Shell\n      description: What shell are you using?\n      placeholder: \"Example: zsh 5.8.1\"\n    validations:\n      required: true\n\n  - type: dropdown\n    attributes:\n      label: Version\n      description: What version of atuin are you running?\n      multiple: false\n      options: # how often will I forget to update this? a lot.\n        - v17.0.0 (Default)\n        - v16.0.0\n        - v15.0.0\n        - v14.0.1\n        - v14.0.0\n        - v13.0.1\n        - v13.0.0\n        - v12.0.0\n        - v11.0.0\n        - v0.10.0\n        - v0.9.1\n        - v0.9.0\n        - v0.8.1\n        - v0.8.0\n        - v0.7.2\n        - v0.7.1\n        - v0.7.0\n        - v0.6.4\n        - v0.6.3\n      default: 0\n    validations:\n      required: true\n\n  - type: checkboxes\n    attributes:\n      label: Self hosted\n      description: Are you self hosting atuin server?\n      options:\n        - label: I am self hosting atuin server\n\n  - type: checkboxes\n    attributes:\n      label: Search the issues\n      description: Did you search the issues and discussions for your problem?\n      options:\n        - label: I checked that someone hasn't already asked about the same issue\n          required: true\n\n  - type: textarea\n    attributes:\n      label: Behaviour\n      description: \"Please describe the issue - what you expected to happen, what actually happened\"\n      \n  - type: textarea\n    attributes:\n      label: Logs\n      description: \"If possible, please include logs from atuin, especially if you self host the server - ATUIN_LOG=debug\"\n\n  - type: textarea\n    attributes:\n      label: Extra information\n      description: \"Anything else you'd like to add?\"\n\n  - type: checkboxes\n    attributes:\n      label: Code of Conduct\n      description: The Code of Conduct helps create a safe space for everyone. We require\n        that everyone agrees to it.\n      options:\n        - label: I agree to follow this project's [Code of Conduct](https://github.com/atuinsh/atuin/blob/main/CODE_OF_CONDUCT.md)\n          required: true\n"
  },
  {
    "path": ".github/FUNDING.yml",
    "content": "# These are supported funding model platforms\n\ngithub: [atuinsh]\npatreon: # Replace with a single Patreon username\nopen_collective: # Replace with a single Open Collective username\nko_fi: # Replace with a single Ko-fi username\ntidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel\ncommunity_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry\nliberapay: # Replace with a single Liberapay username\nissuehunt: # Replace with a single IssueHunt username\notechie: # Replace with a single Otechie username\nlfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry\ncustom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/bug.yaml",
    "content": "name: Bug Report\ndescription: File a bug report\ntitle: \"[Bug]: \"\nlabels: [\"bug\", \"triage\"]\nbody:\n  - type: markdown\n    attributes:\n      value: |\n        Thanks for taking the time to fill out this bug report!\n  - type: textarea\n    id: what-expected\n    attributes:\n      label: What did you expect to happen?\n      placeholder: Tell us what you expected to see!\n    validations:\n      required: true\n  - type: textarea\n    id: what-happened\n    attributes:\n      label: What happened?\n      placeholder: Tell us what you see!\n    validations:\n      required: true\n  - type: textarea\n    id: doctor\n    validations:\n      required: true\n    attributes:\n      label: Atuin doctor output\n      description: Please run 'atuin doctor' and share the output. If it fails to run, share any errors. This requires Atuin >=v18.1.0\n      render: yaml\n  - type: checkboxes\n    id: terms\n    attributes:\n      label: Code of Conduct\n      description: By submitting this issue, you agree to follow our [Code of Conduct](https://github.com/atuinsh/atuin/blob/main/CODE_OF_CONDUCT.md)\n      options:\n        - label: I agree to follow this project's Code of Conduct\n          required: true\n"
  },
  {
    "path": ".github/dependabot.yml",
    "content": "# To get started with Dependabot version updates, you'll need to specify which\n# package ecosystems to update and where the package manifests are located.\n# Please see the documentation for all configuration options:\n# https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates\n\nversion: 2\nupdates:\n  - package-ecosystem: \"cargo\" # See documentation for possible values\n    directory: \"/\" # Location of package manifests\n    schedule:\n      interval: \"weekly\"\n  - package-ecosystem: \"docker\" # See documentation for possible values\n    directory: \"/\" # Location of package manifests\n    schedule:\n      interval: \"weekly\"\n  - package-ecosystem: \"github-actions\"\n    directory: \"/\"\n    schedule:\n      interval: \"weekly\"\n"
  },
  {
    "path": ".github/pull_request_template.md",
    "content": "<!-- Thank you for making a PR! Bug fixes are always welcome, but if you're adding a new feature or changing an existing one, we'd really appreciate if you open an issue, post on the forum, or drop in on Discord -->\n\n## Checks\n- [ ] I am happy for maintainers to push small adjustments to this PR, to speed up the review cycle\n- [ ] I have checked that there are no existing pull requests for the same thing\n"
  },
  {
    "path": ".github/workflows/codespell.yml",
    "content": "# Codespell configuration is within .codespellrc\n---\nname: Codespell\n\non:\n  push:\n    branches: [main]\n  pull_request:\n    branches: [main]\n\npermissions:\n  contents: read\n\njobs:\n  codespell:\n    name: Check for spelling errors\n    runs-on: depot-ubuntu-24.04\n\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v6\n      - name: Codespell\n        uses: codespell-project/actions-codespell@v2\n        with:\n          # This is regenerated from commit history\n          # we cannot rewrite commit history, and I'd rather not correct it\n          # every time\n          exclude_file: CHANGELOG.md\n"
  },
  {
    "path": ".github/workflows/docker.yaml",
    "content": "name: build-docker\n\non:\n  push:\n    branches: [main]\n\njobs:\n  publish:\n    concurrency:\n      group: ${{ github.ref }}-docker\n      cancel-in-progress: true\n    permissions:\n      packages: write\n      contents: read\n      id-token: write\n\n    runs-on: depot-ubuntu-24.04\n    steps:\n      - uses: actions/checkout@v6\n\n      - name: Get Repo Owner\n        id: get_repo_owner\n        run: echo \"REPO_OWNER=$(echo ${{ github.repository_owner }} | tr '[:upper:]' '[:lower:]')\" > $GITHUB_ENV\n\n      - uses: depot/setup-action@v1\n\n      - name: Login to container Registry\n        uses: docker/login-action@v3\n        with:\n          username: ${{ github.repository_owner }}\n          password: ${{ secrets.GITHUB_TOKEN }}\n          registry: ghcr.io\n\n      - name: Get short sha\n        id: shortsha\n        run: echo \"short_sha=$(git rev-parse --short HEAD)\" >> $GITHUB_OUTPUT\n\n      - name: Build and push\n        uses: depot/build-push-action@v1\n        with:\n          push: true\n          platforms: linux/amd64,linux/arm64\n          file: ./Dockerfile\n          context: .\n          provenance: false\n          build-args: |\n            Version=dev\n            GitCommit=${{ steps.shortsha.outputs.short_sha }}\n          tags: |\n            ghcr.io/${{ env.REPO_OWNER }}/atuin:${{ steps.shortsha.outputs.short_sha }}\n"
  },
  {
    "path": ".github/workflows/installer.yml",
    "content": "name: Install\n\non:\n  push:\n    branches: [main]\n  pull_request:\n    paths: .github/workflows/installer.yml\n\nenv:\n  CARGO_TERM_COLOR: always\n\njobs:\n  install:\n    strategy:\n      matrix:\n        os: [depot-ubuntu-24.04, macos-14]\n    runs-on: ${{ matrix.os }}\n\n    steps:\n      - uses: actions/checkout@v6\n\n      - name: Install zsh for ubuntu\n        if: matrix.os == 'depot-ubuntu-24.04' \n        run: | \n          sudo apt install zsh\n\n      - name: Test install script on bash\n        run: | \n          /bin/bash -c \"$(curl --proto '=https' --tlsv1.2 -sSf https://setup.atuin.sh)\"\n          [ -d \"$HOME/.atuin\" ] && source $HOME/.atuin/bin/env\n          atuin --help\n\n      - name: Test install script on zsh\n        shell: zsh {0}\n        run: | \n          /bin/bash -c \"$(curl --proto '=https' --tlsv1.2 -sSf https://setup.atuin.sh)\"\n          [ -d \"$HOME/.atuin\" ] && source $HOME/.atuin/bin/env\n          atuin --help\n"
  },
  {
    "path": ".github/workflows/nix.yml",
    "content": "# Verify the Nix build is working\n# Failures will usually occur due to an out of date Rust version\n# That can be updated to the latest version in nixpkgs-unstable with `nix flake update`\nname: Nix\non:\n  push:\n    branches: [ main ]\n    paths-ignore:\n      - 'ui/**'\n  pull_request:\n    branches: [ main ]\n    paths-ignore:\n      - 'ui/**'\n\njobs:\n  check:\n    runs-on: depot-ubuntu-24.04\n\n    steps:\n      - uses: actions/checkout@v6\n      - uses: cachix/install-nix-action@v31\n\n      - name: Run nix flake check\n        run: nix flake check --print-build-logs\n\n  build-test:\n    runs-on: depot-ubuntu-24.04\n\n    steps:\n      - uses: actions/checkout@v6\n      - uses: cachix/install-nix-action@v31\n\n      - name: Run nix build\n        run: nix build --print-build-logs\n"
  },
  {
    "path": ".github/workflows/release.yml",
    "content": "# This file was autogenerated by dist: https://axodotdev.github.io/cargo-dist\n#\n# Copyright 2022-2024, axodotdev\n# SPDX-License-Identifier: MIT or Apache-2.0\n#\n# CI that:\n#\n# * checks for a Git Tag that looks like a release\n# * builds artifacts with dist (archives, installers, hashes)\n# * uploads those artifacts to temporary workflow zip\n# * on success, uploads the artifacts to a GitHub Release\n#\n# Note that the GitHub Release will be created with a generated\n# title/body based on your changelogs.\n\nname: Release\npermissions:\n  \"contents\": \"write\"\n\n# This task will run whenever you push a git tag that looks like a version\n# like \"1.0.0\", \"v0.1.0-prerelease.1\", \"my-app/0.1.0\", \"releases/v1.0.0\", etc.\n# Various formats will be parsed into a VERSION and an optional PACKAGE_NAME, where\n# PACKAGE_NAME must be the name of a Cargo package in your workspace, and VERSION\n# must be a Cargo-style SemVer Version (must have at least major.minor.patch).\n#\n# If PACKAGE_NAME is specified, then the announcement will be for that\n# package (erroring out if it doesn't have the given version or isn't dist-able).\n#\n# If PACKAGE_NAME isn't specified, then the announcement will be for all\n# (dist-able) packages in the workspace with that version (this mode is\n# intended for workspaces with only one dist-able package, or with all dist-able\n# packages versioned/released in lockstep).\n#\n# If you push multiple tags at once, separate instances of this workflow will\n# spin up, creating an independent announcement for each one. However, GitHub\n# will hard limit this to 3 tags per commit, as it will assume more tags is a\n# mistake.\n#\n# If there's a prerelease-style suffix to the version, then the release(s)\n# will be marked as a prerelease.\non:\n  pull_request:\n  push:\n    tags:\n      - '**[0-9]+.[0-9]+.[0-9]+*'\n\njobs:\n  # Run 'dist plan' (or host) to determine what tasks we need to do\n  plan:\n    runs-on: \"ubuntu-22.04\"\n    outputs:\n      val: ${{ steps.plan.outputs.manifest }}\n      tag: ${{ !github.event.pull_request && github.ref_name || '' }}\n      tag-flag: ${{ !github.event.pull_request && format('--tag={0}', github.ref_name) || '' }}\n      publishing: ${{ !github.event.pull_request }}\n    env:\n      GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n    steps:\n      - uses: actions/checkout@v6\n        with:\n          persist-credentials: false\n          submodules: recursive\n      - name: Install dist\n        # we specify bash to get pipefail; it guards against the `curl` command\n        # failing. otherwise `sh` won't catch that `curl` returned non-0\n        shell: bash\n        run: \"curl --proto '=https' --tlsv1.2 -LsSf https://github.com/axodotdev/cargo-dist/releases/download/v0.31.0/cargo-dist-installer.sh | sh\"\n      - name: Cache dist\n        uses: actions/upload-artifact@v6\n        with:\n          name: cargo-dist-cache\n          path: ~/.cargo/bin/dist\n      # sure would be cool if github gave us proper conditionals...\n      # so here's a doubly-nested ternary-via-truthiness to try to provide the best possible\n      # functionality based on whether this is a pull_request, and whether it's from a fork.\n      # (PRs run on the *source* but secrets are usually on the *target* -- that's *good*\n      # but also really annoying to build CI around when it needs secrets to work right.)\n      - id: plan\n        run: |\n          dist ${{ (!github.event.pull_request && format('host --steps=create --tag={0}', github.ref_name)) || 'plan' }} --output-format=json > plan-dist-manifest.json\n          echo \"dist ran successfully\"\n          cat plan-dist-manifest.json\n          echo \"manifest=$(jq -c \".\" plan-dist-manifest.json)\" >> \"$GITHUB_OUTPUT\"\n      - name: \"Upload dist-manifest.json\"\n        uses: actions/upload-artifact@v6\n        with:\n          name: artifacts-plan-dist-manifest\n          path: plan-dist-manifest.json\n\n  # Build and packages all the platform-specific things\n  build-local-artifacts:\n    name: build-local-artifacts (${{ join(matrix.targets, ', ') }})\n    # Let the initial task tell us to not run (currently very blunt)\n    needs:\n      - plan\n    if: ${{ fromJson(needs.plan.outputs.val).ci.github.artifacts_matrix.include != null && (needs.plan.outputs.publishing == 'true' || fromJson(needs.plan.outputs.val).ci.github.pr_run_mode == 'upload') }}\n    strategy:\n      fail-fast: false\n      # Target platforms/runners are computed by dist in create-release.\n      # Each member of the matrix has the following arguments:\n      #\n      # - runner: the github runner\n      # - dist-args: cli flags to pass to dist\n      # - install-dist: expression to run to install dist on the runner\n      #\n      # Typically there will be:\n      # - 1 \"global\" task that builds universal installers\n      # - N \"local\" tasks that build each platform's binaries and platform-specific installers\n      matrix: ${{ fromJson(needs.plan.outputs.val).ci.github.artifacts_matrix }}\n    runs-on: ${{ matrix.runner }}\n    container: ${{ matrix.container && matrix.container.image || null }}\n    env:\n      GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n      BUILD_MANIFEST_NAME: target/distrib/${{ join(matrix.targets, '-') }}-dist-manifest.json\n    permissions:\n      \"attestations\": \"write\"\n      \"contents\": \"read\"\n      \"id-token\": \"write\"\n    steps:\n      - name: enable windows longpaths\n        run: |\n          git config --global core.longpaths true\n      - uses: actions/checkout@v6\n        with:\n          persist-credentials: false\n          submodules: recursive\n      - name: Install Rust non-interactively if not already installed\n        if: ${{ matrix.container }}\n        run: |\n          if ! command -v cargo > /dev/null 2>&1; then\n            curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y\n            echo \"$HOME/.cargo/bin\" >> $GITHUB_PATH\n          fi\n      - name: Install dist\n        run: ${{ matrix.install_dist.run }}\n      # Get the dist-manifest\n      - name: Fetch local artifacts\n        uses: actions/download-artifact@v7\n        with:\n          pattern: artifacts-*\n          path: target/distrib/\n          merge-multiple: true\n      - name: Install dependencies\n        run: |\n          ${{ matrix.packages_install }}\n      - name: Build artifacts\n        run: |\n          # Actually do builds and make zips and whatnot\n          dist build ${{ needs.plan.outputs.tag-flag }} --print=linkage --output-format=json ${{ matrix.dist_args }} > dist-manifest.json\n          echo \"dist ran successfully\"\n      - name: Attest\n        uses: actions/attest-build-provenance@v3\n        with:\n          subject-path: \"target/distrib/*${{ join(matrix.targets, ', ') }}*\"\n      - id: cargo-dist\n        name: Post-build\n        # We force bash here just because github makes it really hard to get values up\n        # to \"real\" actions without writing to env-vars, and writing to env-vars has\n        # inconsistent syntax between shell and powershell.\n        shell: bash\n        run: |\n          # Parse out what we just built and upload it to scratch storage\n          echo \"paths<<EOF\" >> \"$GITHUB_OUTPUT\"\n          dist print-upload-files-from-manifest --manifest dist-manifest.json >> \"$GITHUB_OUTPUT\"\n          echo \"EOF\" >> \"$GITHUB_OUTPUT\"\n\n          cp dist-manifest.json \"$BUILD_MANIFEST_NAME\"\n      - name: \"Upload artifacts\"\n        uses: actions/upload-artifact@v6\n        with:\n          name: artifacts-build-local-${{ join(matrix.targets, '_') }}\n          path: |\n            ${{ steps.cargo-dist.outputs.paths }}\n            ${{ env.BUILD_MANIFEST_NAME }}\n\n  # Build and package all the platform-agnostic(ish) things\n  build-global-artifacts:\n    needs:\n      - plan\n      - build-local-artifacts\n    runs-on: \"ubuntu-22.04\"\n    env:\n      GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n      BUILD_MANIFEST_NAME: target/distrib/global-dist-manifest.json\n    steps:\n      - uses: actions/checkout@v6\n        with:\n          persist-credentials: false\n          submodules: recursive\n      - name: Install cached dist\n        uses: actions/download-artifact@v7\n        with:\n          name: cargo-dist-cache\n          path: ~/.cargo/bin/\n      - run: chmod +x ~/.cargo/bin/dist\n      # Get all the local artifacts for the global tasks to use (for e.g. checksums)\n      - name: Fetch local artifacts\n        uses: actions/download-artifact@v7\n        with:\n          pattern: artifacts-*\n          path: target/distrib/\n          merge-multiple: true\n      - id: cargo-dist\n        shell: bash\n        run: |\n          dist build ${{ needs.plan.outputs.tag-flag }} --output-format=json \"--artifacts=global\" > dist-manifest.json\n          echo \"dist ran successfully\"\n\n          # Parse out what we just built and upload it to scratch storage\n          echo \"paths<<EOF\" >> \"$GITHUB_OUTPUT\"\n          jq --raw-output \".upload_files[]\" dist-manifest.json >> \"$GITHUB_OUTPUT\"\n          echo \"EOF\" >> \"$GITHUB_OUTPUT\"\n\n          cp dist-manifest.json \"$BUILD_MANIFEST_NAME\"\n      - name: \"Upload artifacts\"\n        uses: actions/upload-artifact@v6\n        with:\n          name: artifacts-build-global\n          path: |\n            ${{ steps.cargo-dist.outputs.paths }}\n            ${{ env.BUILD_MANIFEST_NAME }}\n  # Determines if we should publish/announce\n  host:\n    needs:\n      - plan\n      - build-local-artifacts\n      - build-global-artifacts\n    # Only run if we're \"publishing\", and only if plan, local and global didn't fail (skipped is fine)\n    if: ${{ always() && needs.plan.result == 'success' && needs.plan.outputs.publishing == 'true' && (needs.build-global-artifacts.result == 'skipped' || needs.build-global-artifacts.result == 'success') && (needs.build-local-artifacts.result == 'skipped' || needs.build-local-artifacts.result == 'success') }}\n    env:\n      GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n    runs-on: \"ubuntu-22.04\"\n    outputs:\n      val: ${{ steps.host.outputs.manifest }}\n    steps:\n      - uses: actions/checkout@v6\n        with:\n          persist-credentials: false\n          submodules: recursive\n      - name: Install cached dist\n        uses: actions/download-artifact@v7\n        with:\n          name: cargo-dist-cache\n          path: ~/.cargo/bin/\n      - run: chmod +x ~/.cargo/bin/dist\n      # Fetch artifacts from scratch-storage\n      - name: Fetch artifacts\n        uses: actions/download-artifact@v7\n        with:\n          pattern: artifacts-*\n          path: target/distrib/\n          merge-multiple: true\n      - id: host\n        shell: bash\n        run: |\n          dist host ${{ needs.plan.outputs.tag-flag }} --steps=upload --steps=release --output-format=json > dist-manifest.json\n          echo \"artifacts uploaded and released successfully\"\n          cat dist-manifest.json\n          echo \"manifest=$(jq -c \".\" dist-manifest.json)\" >> \"$GITHUB_OUTPUT\"\n      - name: \"Upload dist-manifest.json\"\n        uses: actions/upload-artifact@v6\n        with:\n          # Overwrite the previous copy\n          name: artifacts-dist-manifest\n          path: dist-manifest.json\n      # Create a GitHub Release while uploading all files to it\n      - name: \"Download GitHub Artifacts\"\n        uses: actions/download-artifact@v7\n        with:\n          pattern: artifacts-*\n          path: artifacts\n          merge-multiple: true\n      - name: Cleanup\n        run: |\n          # Remove the granular manifests\n          rm -f artifacts/*-dist-manifest.json\n      - name: Create GitHub Release\n        env:\n          PRERELEASE_FLAG: \"${{ fromJson(steps.host.outputs.manifest).announcement_is_prerelease && '--prerelease' || '' }}\"\n          ANNOUNCEMENT_TITLE: \"${{ fromJson(steps.host.outputs.manifest).announcement_title }}\"\n          ANNOUNCEMENT_BODY: \"${{ fromJson(steps.host.outputs.manifest).announcement_github_body }}\"\n          RELEASE_COMMIT: \"${{ github.sha }}\"\n        run: |\n          # Write and read notes from a file to avoid quoting breaking things\n          echo \"$ANNOUNCEMENT_BODY\" > $RUNNER_TEMP/notes.txt\n\n          gh release create \"${{ needs.plan.outputs.tag }}\" --target \"$RELEASE_COMMIT\" $PRERELEASE_FLAG --title \"$ANNOUNCEMENT_TITLE\" --notes-file \"$RUNNER_TEMP/notes.txt\" artifacts/*\n\n  announce:\n    needs:\n      - plan\n      - host\n    # use \"always() && ...\" to allow us to wait for all publish jobs while\n    # still allowing individual publish jobs to skip themselves (for prereleases).\n    # \"host\" however must run to completion, no skipping allowed!\n    if: ${{ always() && needs.host.result == 'success' }}\n    runs-on: \"ubuntu-22.04\"\n    env:\n      GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n    steps:\n      - uses: actions/checkout@v6\n        with:\n          persist-credentials: false\n          submodules: recursive\n"
  },
  {
    "path": ".github/workflows/rust.yml",
    "content": "name: Rust\n\non:\n  push:\n    branches: [main]\n    paths-ignore:\n      - \"ui/**\"\n  pull_request:\n    branches: [main]\n    paths-ignore:\n      - \"ui/**\"\n\nenv:\n  CARGO_TERM_COLOR: always\n\njobs:\n  build:\n    strategy:\n      matrix:\n        os: [depot-ubuntu-24.04, macos-14, windows-latest]\n    runs-on: ${{ matrix.os }}\n\n    steps:\n      - uses: actions/checkout@v6\n\n      - name: Install rust\n        uses: dtolnay/rust-toolchain@master\n        with:\n          toolchain: 1.94.0\n\n      - uses: actions/cache@v5\n        with:\n          path: |\n            ~/.cargo/registry\n            ~/.cargo/git\n            target\n          key: ${{ runner.os }}-cargo-release-${{ hashFiles('**/Cargo.lock') }}\n\n      - name: Run cargo build common\n        run: cargo build -p atuin-common --locked --release\n\n      - name: Run cargo build client\n        run: cargo build -p atuin-client --locked --release\n\n      - name: Run cargo build server\n        run: cargo build -p atuin-server --locked --release\n\n      - name: Run cargo build main\n        run: cargo build --all --locked --release\n\n  cross-compile:\n    strategy:\n      matrix:\n        # There was an attempt to make cross-compiles also work on FreeBSD, but that failed with:\n        #\n        # warning: libelf.so.2, needed by <...>/libkvm.so, not found (try using -rpath or -rpath-link)\n        target: [x86_64-unknown-illumos]\n    runs-on: depot-ubuntu-24.04\n    steps:\n      - uses: actions/checkout@v6\n\n      - name: Install cross\n        uses: taiki-e/install-action@v2\n        with:\n          tool: cross\n\n      - uses: actions/cache@v5\n        with:\n          path: |\n            ~/.cargo/registry\n            ~/.cargo/git\n            target\n          key: ${{ matrix.target }}-cross-compile-${{ hashFiles('**/Cargo.lock') }}\n\n      - name: Run cross build common\n        run: cross build -p atuin-common --locked --target ${{ matrix.target }}\n\n      - name: Run cross build client\n        run: cross build -p atuin-client --locked --target ${{ matrix.target }}\n\n      - name: Run cross build server\n        run: cross build -p atuin-server --locked --target ${{ matrix.target }}\n\n      - name: Run cross build main\n        run: |\n          cross build --all --locked --target ${{ matrix.target }}\n\n  unit-test:\n    strategy:\n      matrix:\n        os: [depot-ubuntu-24.04, macos-14, windows-latest]\n    runs-on: ${{ matrix.os }}\n\n    steps:\n      - uses: actions/checkout@v6\n\n      - name: Install rust\n        uses: dtolnay/rust-toolchain@master\n        with:\n          toolchain: 1.94.0\n\n      - uses: taiki-e/install-action@v2\n        name: Install nextest\n        with:\n          tool: cargo-nextest\n\n      - uses: actions/cache@v5\n        with:\n          path: |\n            ~/.cargo/registry\n             ~/.cargo/git\n             target\n          key: ${{ runner.os }}-cargo-debug-${{ hashFiles('**/Cargo.lock') }}\n\n      - name: Run cargo test\n        run: cargo nextest run --lib --bins\n\n  check:\n    strategy:\n      matrix:\n        os: [depot-ubuntu-24.04, macos-14, windows-latest]\n    runs-on: ${{ matrix.os }}\n\n    steps:\n      - uses: actions/checkout@v6\n\n      - name: Install rust\n        uses: dtolnay/rust-toolchain@master\n        with:\n          toolchain: 1.94.0\n\n      - uses: actions/cache@v5\n        with:\n          path: |\n            ~/.cargo/registry\n             ~/.cargo/git\n             target\n          key: ${{ runner.os }}-cargo-debug-${{ hashFiles('**/Cargo.lock') }}\n\n      - name: Run cargo check (all features)\n        run: cargo check --all-features --workspace\n\n      - name: Run cargo check (no features)\n        run: cargo check --no-default-features --workspace\n\n      - name: Run cargo check (sync)\n        run: cargo check --no-default-features --features sync --workspace\n\n      - name: Run cargo check (server)\n        run: cargo check -p atuin-server\n\n      - name: Run cargo check (client only)\n        run: cargo check --no-default-features --features client --workspace\n\n  integration-test:\n    runs-on: depot-ubuntu-24.04\n\n    services:\n      postgres:\n        image: postgres\n        env:\n          POSTGRES_USER: atuin\n          POSTGRES_PASSWORD: pass\n          POSTGRES_DB: atuin\n        ports:\n          - 5432:5432\n\n    steps:\n      - uses: actions/checkout@v6\n\n      - name: Install rust\n        uses: dtolnay/rust-toolchain@master\n        with:\n          toolchain: 1.94.0\n\n      - uses: taiki-e/install-action@v2\n        name: Install nextest\n        with:\n          tool: cargo-nextest\n\n      - uses: actions/cache@v5\n        with:\n          path: |\n            ~/.cargo/registry\n             ~/.cargo/git\n             target\n          key: ${{ runner.os }}-cargo-debug-${{ hashFiles('**/Cargo.lock') }}\n\n      - name: Run cargo test\n        run: cargo nextest run --test '*'\n        env:\n          ATUIN_DB_URI: postgres://atuin:pass@localhost:5432/atuin\n\n  clippy:\n    runs-on: depot-ubuntu-24.04\n\n    steps:\n      - uses: actions/checkout@v6\n\n      - name: Install latest rust\n        uses: dtolnay/rust-toolchain@master\n        with:\n          toolchain: 1.94.0\n          components: clippy\n\n      - uses: actions/cache@v5\n        with:\n          path: |\n            ~/.cargo/registry\n            ~/.cargo/git\n            target\n          key: ${{ runner.os }}-cargo-debug-${{ hashFiles('**/Cargo.lock') }}\n\n      - name: Run clippy\n        run: cargo clippy -- -D warnings -D clippy::redundant_clone\n\n  format:\n    runs-on: depot-ubuntu-24.04\n\n    steps:\n      - uses: actions/checkout@v6\n\n      - name: Install latest rust\n        uses: dtolnay/rust-toolchain@master\n        with:\n          toolchain: 1.94.0\n          components: rustfmt\n\n      - name: Format\n        run: cargo fmt -- --check\n"
  },
  {
    "path": ".github/workflows/shellcheck.yml",
    "content": "name: Shellcheck\n\non:\n  push:\n    branches: [ main ]\n  pull_request:\n    branches: [ main ]\n\njobs:\n  shellcheck:\n    runs-on: depot-ubuntu-24.04\n\n    steps:\n    - uses: actions/checkout@v6\n    - name: Run shellcheck\n      uses: ludeeus/action-shellcheck@master\n      env:\n        SHELLCHECK_OPTS: \"-e SC2148\"\n"
  },
  {
    "path": ".github/workflows/update-nix-deps.yml",
    "content": "name: Update Nix Deps\non:\n  workflow_dispatch: # allows manual triggering\n  schedule:\n    - cron: '0 0 1 * *' # runs monthly on the first day of the month at 00:00\n\njobs:\n  lockfile:\n    runs-on: depot-ubuntu-24.04\n    if: github.repository == 'atuinsh/atuin'\n    steps:\n      - name: Checkout repository\n        uses: actions/checkout@v6\n      - name: Install Nix\n        uses: DeterminateSystems/nix-installer-action@main\n      - name: Update flake.lock\n        uses: DeterminateSystems/update-flake-lock@main\n        with:\n          pr-title: \"chore(deps): update flake.lock\"\n          pr-labels: |\n            dependencies\n"
  },
  {
    "path": ".gitignore",
    "content": ".DS_Store\n/target\n*/target\n.env\n.idea/\n.vscode/\nresult\npublish.sh\n.envrc\n.planning/\n\nui/backend/target\nui/backend/gen\n\nsqlite-server.db*\n"
  },
  {
    "path": ".mailmap",
    "content": "networkException <git@nwex.de> <github@nwex.de>\nViolet Shreve <github@shreve.io> <jacob@shreve.io>\nChris Rose <offline@offby1.net> <offbyone@github.com>\nConrad Ludgate <conradludgate@gmail.com> <conrad.ludgate@truelayer.com>\nCristian Le <github@lecris.me> <cristian.le@mpsd.mpg.de>\nDennis Trautwein <git@dtrautwein.eu> <dennis.trautwein@posteo.de>\nEllie Huxtable <ellie@atuin.sh> <e@elm.sh> \nEllie Huxtable <ellie@atuin.sh> <ellie@elliehuxtable.com> \nFrank Hamand <frankhamand@gmail.com> <frank.hamand@coinbase.com>\nJakob Schrettenbrunner <dev@schrej.net> <jakob.schrettenbrunner@telekom.de>\nNemo157 <git@nemo157.com> <github@nemo157.com>\nRichard de Boer <git@tubul.net> <github@tubul.net>\nSandro <sandro.jaeckel@gmail.com> <sandro.jaeckel@sap.com>\nTymanWasTaken <tbeckman530@gmail.com> <ty@blahaj.land>\n"
  },
  {
    "path": ".rustfmt.toml",
    "content": "reorder_imports = true\n# uncomment once stable\n#imports_granularity = \"crate\"\n#group_imports = \"StdExternalCrate\"\n"
  },
  {
    "path": "AGENTS.md",
    "content": "# Atuin\n\nShell history tool. Replaces your shell's built-in history with a SQLite database, adds context (cwd, exit code, duration, hostname), and optionally syncs across machines with end-to-end encryption.\n\n## Workspace crates\n\n```\natuin                  CLI binary + TUI (clap, ratatui, crossterm)\natuin-client           Client library: local DB, encryption, sync, settings\natuin-common           Shared types, API models, utils\natuin-daemon           Background gRPC daemon (tonic) for shell hooks\natuin-dotfiles         Alias/var sync via record store\natuin-history          Sorting algorithms, stats\natuin-kv               Key-value store (synced)\natuin-scripts          Script management (minijinja)\natuin-server           HTTP sync server (axum) - lib + standalone binary\natuin-server-database  Database trait for server\natuin-server-postgres  Postgres implementation (sqlx)\natuin-server-sqlite    SQLite implementation (sqlx)\n```\n\n## Two sync protocols\n\n- **V1 (legacy)**: Syncs history entries directly. Being phased out. Toggleable via `sync_v1_enabled`.\n- **V2 (current)**: Record store abstraction. All data types (history, KV, aliases, vars, scripts) share the same sync infrastructure using tagged records. Envelope-encrypted with PASETO V4 and per-record CEKs.\n\n## Encryption\n\n- **V1**: XSalsa20Poly1305 (secretbox). Key at `~/.local/share/atuin/key`.\n- **V2**: PASETO V4 Local (XChaCha20-Poly1305 + Blake2b). Envelope encryption: each record gets a random CEK wrapped with the master key. Record metadata (id, idx, version, tag, host) is authenticated as implicit assertions.\n\n## Databases\n\n- **Client**: SQLite everywhere. Separate DBs for history, record store, KV, scripts. All use sqlx + WAL mode.\n- **Server**: Postgres (primary) or SQLite. Auto-detected from URI prefix.\n- Migrations live alongside each crate. Never modify existing migrations, only add new ones.\n\n## Hot paths\n\n`history start`, `history end`, and `init` skip database initialization for latency. Don't add DB calls to these without good reason.\n\n## Conventions\n\n- Rust 2024 edition, toolchain 1.93.1.\n- Errors: `eyre::Result` in binaries, `thiserror` for typed errors in libraries.\n- Async: tokio. Client uses `current_thread`; server uses `multi_thread`.\n- `#![deny(unsafe_code)]` on client/common, `#![forbid(unsafe_code)]` on server.\n- Clippy: `pedantic` + `nursery` on main crate. CI enforces `-D warnings -D clippy::redundant_clone`.\n- Format: `cargo fmt`. Only non-default: `reorder_imports = true`.\n- IDs: UUIDv7 (time-ordered), newtype wrappers (`HistoryId`, `RecordId`, `HostId`).\n- Serialization: MessagePack for encrypted payloads, JSON for API, TOML for config.\n- Storage traits: `Database` (client), `Store` (record store), `Database` (server) -- all `async_trait`.\n- History builders: `HistoryImported`, `HistoryCaptured`, `HistoryFromDb` with compile-time field validation.\n- Feature flags: `client`, `sync`, `daemon`, `clipboard`, `check-update`.\n\n## Testing\n\n- Unit tests inline with `#[cfg(test)]`, async via `#[tokio::test]`.\n- Integration tests in `crates/atuin/tests/` need Postgres (`ATUIN_DB_URI` env var).\n- Use `\":memory:\"` SQLite for unit tests needing a database.\n- Runner: `cargo nextest`.\n- Benchmarks: `divan` in `atuin-history`.\n\n## Build and check\n\n```sh\ncargo build\ncargo test\ncargo clippy -- -D warnings\ncargo fmt --check\n```\n"
  },
  {
    "path": "CHANGELOG.md",
    "content": "# Changelog\n\nAll notable changes to this project will be documented in this file.\n\n## [unreleased]\n\n### Bug Fixes\n\n- Nushell 0.111; future Nushell 0.112 support ([#3266](https://github.com/atuinsh/atuin/issues/3266))\n\n\n### Features\n\n- Call atuin setup from install script ([#3265](https://github.com/atuinsh/atuin/issues/3265))\n- Allow headless account ops against Hub server ([#3280](https://github.com/atuinsh/atuin/issues/3280))\n- Add custom filtering and scoring mechanisms\n\n\n### Miscellaneous Tasks\n\n- *(ci)* Migrate to depot runners ([#3279](https://github.com/atuinsh/atuin/issues/3279))\n- *(ci)* Use depot to build docker images too ([#3281](https://github.com/atuinsh/atuin/issues/3281))\n- Update changelog\n- Update permissions in Docker workflow ([#3283](https://github.com/atuinsh/atuin/issues/3283))\n- Change CHANGELOG format to be easier to parse\n- Symlink changelog so dist can pick it up\n- Vendor nucleo-ext + fork, so we can depend on our changes properly ([#3284](https://github.com/atuinsh/atuin/issues/3284))\n\n\n## 18.13.2\n\n### Miscellaneous Tasks\n\n- *(release)* Building windows aarch64 was overly optimistic\n- Update changelog\n\n\n## 18.13.1\n\n### Miscellaneous Tasks\n\n- *(release)* Update dist, remove custom runners\n- Update changelog\n\n\n## 18.13.0\n\n### Bug Fixes\n\n- *(deps)* Add use-dev-tty to crossterm in atuin-ai ([#3185](https://github.com/atuinsh/atuin/issues/3185))\n- *(docs)* Update Postgres volume path in Docker as required by pg18 ([#3174](https://github.com/atuinsh/atuin/issues/3174))\n- Systemd Exec for separate server binary ([#3176](https://github.com/atuinsh/atuin/issues/3176))\n- Multiline commands with fish ([#3179](https://github.com/atuinsh/atuin/issues/3179))\n- Silent DB failures e.g. when disk is full ([#3183](https://github.com/atuinsh/atuin/issues/3183))\n- Forward $PATH to tmux popup in zsh ([#3198](https://github.com/atuinsh/atuin/issues/3198))\n- Dramatically decrease daemon memory usage ([#3211](https://github.com/atuinsh/atuin/issues/3211))\n- Regen cargo dist\n- Clear script database before rebuild to prevent unique constraint violation ([#3232](https://github.com/atuinsh/atuin/issues/3232))\n- Support Nushell 0.111 ([#3249](https://github.com/atuinsh/atuin/issues/3249))\n- Ctrl-c not exiting ai ([#3256](https://github.com/atuinsh/atuin/issues/3256))\n\n\n### Documentation\n\n- Update config.md to remove NuShell support note ([#3190](https://github.com/atuinsh/atuin/issues/3190))\n- Document `search.filters` ([#3195](https://github.com/atuinsh/atuin/issues/3195))\n- Clean up doc references for sqlite-based self-hosting ([#3216](https://github.com/atuinsh/atuin/issues/3216))\n- Document daemon-fuzzy search mode ([#3254](https://github.com/atuinsh/atuin/issues/3254))\n\n\n### Features\n\n- *(docs)* Add Shell Integration and Interoperability docs ([#3163](https://github.com/atuinsh/atuin/issues/3163))\n- `switch-context` ([#3149](https://github.com/atuinsh/atuin/issues/3149))\n- Add Hub authentication for future sync + extra features ([#3010](https://github.com/atuinsh/atuin/issues/3010))\n- Add Atuin AI inline CLI MVP ([#3178](https://github.com/atuinsh/atuin/issues/3178))\n- Add autostart and pid management to daemon ([#3180](https://github.com/atuinsh/atuin/issues/3180))\n- Generate commands or ask questions with `atuin ai` ([#3199](https://github.com/atuinsh/atuin/issues/3199))\n- Add history author/intent metadata and v1 record version ([#3205](https://github.com/atuinsh/atuin/issues/3205))\n- In-memory search index with atuin daemon ([#3201](https://github.com/atuinsh/atuin/issues/3201))\n- Update script for smoother setup ([#3230](https://github.com/atuinsh/atuin/issues/3230))\n- Initial draft of atuin-shell ([#3206](https://github.com/atuinsh/atuin/issues/3206))\n- Allow setting multipliers for frequency, recency, and frecency scores ([#3235](https://github.com/atuinsh/atuin/issues/3235))\n- Allow running `atuin search -i` as subcommand ([#3208](https://github.com/atuinsh/atuin/issues/3208))\n- Use pty proxy for rendering tui popups without clearing the terminal ([#3234](https://github.com/atuinsh/atuin/issues/3234))\n- Allow authenticating with Atuin Hub ([#3237](https://github.com/atuinsh/atuin/issues/3237))\n- Initialize Atuin AI by default with `atuin init` ([#3255](https://github.com/atuinsh/atuin/issues/3255))\n- Add `atuin setup` ([#3257](https://github.com/atuinsh/atuin/issues/3257))\n\n\n### Miscellaneous Tasks\n\n- Update changelog\n- Update changelog\n- Update changelog\n- Use workspace versions ([#3210](https://github.com/atuinsh/atuin/issues/3210))\n- Move atuin ai subcommand into core binary ([#3212](https://github.com/atuinsh/atuin/issues/3212))\n- Update changelog\n- Update to Rust 1.94 ([#3247](https://github.com/atuinsh/atuin/issues/3247))\n- Strip symbols in dist profile to reduce binary size\n- Upgrade thiserror 1.x to 2.x to deduplicate dependency\n- Upgrade axum 0.7 to 0.8 to deduplicate with tonic's axum\n- Update changelog\n- Update changelog\n- Update changelog\n- Update changelog\n\n\n## 18.12.1\n\n### Bug Fixes\n\n- *(shell)* Fix ATUIN_SESSION errors in tmux popup ([#3170](https://github.com/atuinsh/atuin/issues/3170))\n- *(tui)* Enter in vim normal mode, shift-tab keybind ([#3158](https://github.com/atuinsh/atuin/issues/3158))\n- Server start commands for Docker. ([#3160](https://github.com/atuinsh/atuin/issues/3160))\n\n\n### Features\n\n- Expand keybinding system with vim motions, media keys, and inspector improvements ([#3161](https://github.com/atuinsh/atuin/issues/3161))\n- Add original-input-empty keybind condition ([#3171](https://github.com/atuinsh/atuin/issues/3171))\n\n\n### Miscellaneous Tasks\n\n- Update changelog\n\n\n## 18.12.0\n\n### Bug Fixes\n\n- *(powershell)* Preserve `$LASTEXITCODE` ([#3120](https://github.com/atuinsh/atuin/issues/3120))\n- *(powershell)* Display search stderr ([#3146](https://github.com/atuinsh/atuin/issues/3146))\n- *(search)* Allow hyphen-prefixed query args like `---` ([#3129](https://github.com/atuinsh/atuin/issues/3129))\n- *(tui)* Space and F1-F24 keys not handled properly by new keybind system ([#3138](https://github.com/atuinsh/atuin/issues/3138))\n- *(ui)* Don't draw a leading space for command\n- *(ui)* Time column can take up to 9 cells\n- *(ui)* Align cursor with the expand column (usually the command)\n- *(ui)* Align cursor when expand column is in the middle ([#3103](https://github.com/atuinsh/atuin/issues/3103))\n- Zsh import multiline issue ([#2799](https://github.com/atuinsh/atuin/issues/2799))\n- Do not hit sync v1 endpoints for status\n- Do not hit sync v1 endpoints for status ([#3102](https://github.com/atuinsh/atuin/issues/3102))\n- Do not set ATUIN_SESSION if it is already set ([#3107](https://github.com/atuinsh/atuin/issues/3107))\n- Custom data dir test on windows ([#3109](https://github.com/atuinsh/atuin/issues/3109))\n- New session on shlvl change ([#3111](https://github.com/atuinsh/atuin/issues/3111))\n- Larger exit column width on Windows ([#3119](https://github.com/atuinsh/atuin/issues/3119))\n- Halt sync loop if server returns an empty page ([#3122](https://github.com/atuinsh/atuin/issues/3122))\n- Use directories crate for home dir resolution ([#3125](https://github.com/atuinsh/atuin/issues/3125))\n- Tab behaving like enter, eprintln ([#3135](https://github.com/atuinsh/atuin/issues/3135))\n- Issue with shift and modifier keys ([#3143](https://github.com/atuinsh/atuin/issues/3143))\n- Remove invalid IF EXISTS from sqlite drop column migration ([#3145](https://github.com/atuinsh/atuin/issues/3145))\n\n\n### Documentation\n\n- *(CONTRIBUTING)* Update links ([#3117](https://github.com/atuinsh/atuin/issues/3117))\n- *(README)* Update links ([#3116](https://github.com/atuinsh/atuin/issues/3116))\n- *(config)* Clarify scope of directory filter_mode ([#3082](https://github.com/atuinsh/atuin/issues/3082))\n- *(configuration)* Describe new utility \"atuin-bind\" for Bash ([#3064](https://github.com/atuinsh/atuin/issues/3064))\n- *(installation)* Add mise alternative installation method ([#3066](https://github.com/atuinsh/atuin/issues/3066))\n- Various improvements to the `atuin import` docs ([#3062](https://github.com/atuinsh/atuin/issues/3062))\n- Disambiguate 'setup' (noun) vs. 'set up' (verb) ([#3061](https://github.com/atuinsh/atuin/issues/3061))\n- Fix punctuation and grammar in basic usage guide ([#3063](https://github.com/atuinsh/atuin/issues/3063))\n- Expand and clarify usage of the history prune command ([#3084](https://github.com/atuinsh/atuin/issues/3084))\n- Small edit to themes website file ([#3069](https://github.com/atuinsh/atuin/issues/3069))\n- Config/ with initial uid:gid\n- Add PowerShell install instructions\n- Add PowerShell and Windows install instructions ([#3096](https://github.com/atuinsh/atuin/issues/3096))\n- Update the `[keys]` docs ([#3114](https://github.com/atuinsh/atuin/issues/3114))\n- Add history deletion guide ([#3130](https://github.com/atuinsh/atuin/issues/3130))\n- Update how to use Docker to self-host ([#3148](https://github.com/atuinsh/atuin/issues/3148))\n- Add IRC contact information to README\n\n\n### Features\n\n- *(dotfiles)* Add sort and filter options to alias/var list ([#3131](https://github.com/atuinsh/atuin/issues/3131))\n- *(theme)* Note new default theme name and syntax ([#3080](https://github.com/atuinsh/atuin/issues/3080))\n- *(tui)* Add clear-to-start/end actions ([#3141](https://github.com/atuinsh/atuin/issues/3141))\n- *(ui)* Highlight fulltext search as fulltext search instead of fuzzy search\n- *(ui)* Highlight fulltext search as fulltext search instead of fuzzy search ([#3098](https://github.com/atuinsh/atuin/issues/3098))\n- *(ultracompact)* Adds setting for ultracompact mode ([#3079](https://github.com/atuinsh/atuin/issues/3079))\n- Add custom column support ([#3089](https://github.com/atuinsh/atuin/issues/3089))\n- Left arrow/backspace on empty to start edit ([#3090](https://github.com/atuinsh/atuin/issues/3090))\n- Add more vim movement bindings for navigation ([#3041](https://github.com/atuinsh/atuin/issues/3041))\n- Support setting a custom data dir in config ([#3105](https://github.com/atuinsh/atuin/issues/3105))\n- Remove user verification functionality ([#3108](https://github.com/atuinsh/atuin/issues/3108))\n- Add option to use tmux display-popup ([#3058](https://github.com/atuinsh/atuin/issues/3058))\n- Move atuin-server to its own binary ([#3112](https://github.com/atuinsh/atuin/issues/3112))\n- Add a parameter to the sync to specify the download/upload page ([#2408](https://github.com/atuinsh/atuin/issues/2408))\n- Replace several files with a sqlite db ([#3128](https://github.com/atuinsh/atuin/issues/3128))\n- Add new custom keybinding system for search TUI ([#3127](https://github.com/atuinsh/atuin/issues/3127))\n\n\n### Miscellaneous Tasks\n\n- Remove total_history from api index response ([#3094](https://github.com/atuinsh/atuin/issues/3094))\n  - **BREAKING**: remove total_history from api index response ([#3094](https://github.com/atuinsh/atuin/issues/3094))\n- Update to rust 1.93\n- Update to rust 1.93 ([#3101](https://github.com/atuinsh/atuin/issues/3101))\n- Update changelog\n- Update agents.md ([#3126](https://github.com/atuinsh/atuin/issues/3126))\n- Update changelog\n- Update changelog\n- Update changelog\n\n\n### Theming\n\n- Explain how to set ANSI codes directly ([#3065](https://github.com/atuinsh/atuin/issues/3065))\n\n\n### Faq\n\n- Add alternative projects ([#3076](https://github.com/atuinsh/atuin/issues/3076))\n\n\n## 18.11.0\n\n### Bug Fixes\n\n- *(bash)* Fix issues with intermediate key sequences in the vi editing mode ([#2977](https://github.com/atuinsh/atuin/issues/2977))\n- *(bash)* Work around a keybinding bug of Bash 5.1 ([#2975](https://github.com/atuinsh/atuin/issues/2975))\n- *(bash/blesh)* Suppress error message for auto-complete source ([#2976](https://github.com/atuinsh/atuin/issues/2976))\n- *(powershell)* Run `atuin history end` in the background ([#3034](https://github.com/atuinsh/atuin/issues/3034))\n- *(powershell)* Add error safety and cleanup ([#3040](https://github.com/atuinsh/atuin/issues/3040))\n- Highlight the correct place when multibyte characters are involved ([#2965](https://github.com/atuinsh/atuin/issues/2965))\n- Prevent interactive search crash when update check fails ([#3016](https://github.com/atuinsh/atuin/issues/3016))\n- Move thorough search through search.filters w/ workspaces ([#2703](https://github.com/atuinsh/atuin/issues/2703))\n\n\n### Documentation\n\n- Migrate docs from separate repo to `docs` subfolder ([#3018](https://github.com/atuinsh/atuin/issues/3018))\n\n\n### Features\n\n- Support additional history filenames in replxx importer ([#3005](https://github.com/atuinsh/atuin/issues/3005))\n- Add colors to --help/-h ([#3000](https://github.com/atuinsh/atuin/issues/3000))\n- Add support for read replicas to postgres ([#3029](https://github.com/atuinsh/atuin/issues/3029))\n- Allow disabling sync v1 ([#3030](https://github.com/atuinsh/atuin/issues/3030))\n- Consider atuin dotfile aliases when calculating atuin wrapped ([#3048](https://github.com/atuinsh/atuin/issues/3048))\n- Add session and uuid column support to history list ([#3049](https://github.com/atuinsh/atuin/issues/3049))\n\n\n### Miscellaneous Tasks\n\n- *(nix)* Prevent deprecation warning on evaluation ([#3006](https://github.com/atuinsh/atuin/issues/3006))\n- Update changelog\n- Adjust update wording ([#2974](https://github.com/atuinsh/atuin/issues/2974))\n- Add Windows builds, second try ([#2966](https://github.com/atuinsh/atuin/issues/2966))\n- Update to rust 1.91 ([#2981](https://github.com/atuinsh/atuin/issues/2981))\n- Add Atuin Desktop information to install script\n- Remove trailing whitespace ([#2985](https://github.com/atuinsh/atuin/issues/2985))\n- Fix typo ([#2994](https://github.com/atuinsh/atuin/issues/2994))\n- Clarify docstring of the enter_accept config key ([#3003](https://github.com/atuinsh/atuin/issues/3003))\n- Fix github action syntax for variables ([#2998](https://github.com/atuinsh/atuin/issues/2998))\n- Add AGENTS.md\n- Update changelog\n- Remove x86_64 mac from build targets ([#3052](https://github.com/atuinsh/atuin/issues/3052))\n\n\n### Build\n\n- *(nix)* Update rust toolchain hash ([#2990](https://github.com/atuinsh/atuin/issues/2990))\n\n\n## 18.10.0\n\n### Bug Fixes\n\n- Stats ngram window size cli parsing ([#2946](https://github.com/atuinsh/atuin/issues/2946))\n\n\n### Features\n\n- *(bash)* Use Readline's accept-line for enter_accept ([#2953](https://github.com/atuinsh/atuin/issues/2953))\n- Add commit to displayed version info ([#2922](https://github.com/atuinsh/atuin/issues/2922))\n- Add import from PowerShell history ([#2864](https://github.com/atuinsh/atuin/issues/2864))\n- Interactive Inspector ([#2319](https://github.com/atuinsh/atuin/issues/2319))\n- Nu ≥ 0.106.0 support commandline accept ([#2957](https://github.com/atuinsh/atuin/issues/2957))\n\n\n### Miscellaneous Tasks\n\n- Update rusty_paseto and rusty_paserk ([#2942](https://github.com/atuinsh/atuin/issues/2942))\n- Update changelog\n\n\n## 18.9.0\n\n### Bug Fixes\n\n- *(dotfiles)* Properly escape spaces/quotes in vars\n- Clippy issues on Windows ([#2856](https://github.com/atuinsh/atuin/issues/2856))\n- Honor timezone in inspector stats ([#2853](https://github.com/atuinsh/atuin/issues/2853))\n- Make status exit 1 if not logged in ([#2843](https://github.com/atuinsh/atuin/issues/2843))\n- Match logic of theme directory with settings directory, so ATUIN_CONFIG_DIR is respected ([#2707](https://github.com/atuinsh/atuin/issues/2707))\n- Expand path for daemon.socket_path ([#2870](https://github.com/atuinsh/atuin/issues/2870))\n- Use fullscreen if `inline_height` is too large ([#2888](https://github.com/atuinsh/atuin/issues/2888))\n- Clean up new rustc and clippy warnings on Rust 1.89\n- `cargo update` and changes needed to accomodate it\n- Run `cargo fmt`\n- Clippy warnings I don't have on my version of clippy\n- Add forgotten `rust-toolchain.toml` to match changes (oops)\n- Update version in Cargo.toml + github workflows\n- Clippy warnings\n- Dissociate command_chaining from enter_accept\n- Remove __atuin_chain_command__ prefix\n- Docker compose link ([#2914](https://github.com/atuinsh/atuin/issues/2914))\n- Fish up binding ([#2902](https://github.com/atuinsh/atuin/issues/2902))\n\n\n### Features\n\n- *(stats)* Add dotnet to default common subcommands\n- *(tui)* Select entries using number in vim-normal mode. closes #2368 ([#2893](https://github.com/atuinsh/atuin/issues/2893))\n- *(tui)* Add show_numeric_shortcuts config to hide 1-9 shortcuts ([#2766](https://github.com/atuinsh/atuin/issues/2766))\n- Highlight matches in interactive search ([#2653](https://github.com/atuinsh/atuin/issues/2653))\n- Add session-preload filter mode to include global history from before session start\n- Add various acceptance keys ([#2928](https://github.com/atuinsh/atuin/issues/2928))\n- More accurately filter secret tokens ([#2932](https://github.com/atuinsh/atuin/issues/2932))\n- Add shell pipelines to command chaining ([#2938](https://github.com/atuinsh/atuin/issues/2938))\n\n\n### Miscellaneous Tasks\n\n- Update changelog\n- Remove legacy Apple SDK frameworks ([#2885](https://github.com/atuinsh/atuin/issues/2885))\n- Update dist workflows\n- Update to Rust 1.90 ([#2916](https://github.com/atuinsh/atuin/issues/2916))\n\n\n### Refactor\n\n- Shell environment variables\n\n\n### Build\n\n- Update flake.nix with new sha256\n\n\n## 18.8.0\n\n### Bug Fixes\n\n- *(build)* Enable sqlite feature for sqlite server ([#2848](https://github.com/atuinsh/atuin/issues/2848))\n- Make login exit 1 if already logged in ([#2832](https://github.com/atuinsh/atuin/issues/2832))\n- Use transaction for idx consistency checking ([#2840](https://github.com/atuinsh/atuin/issues/2840))\n- Ensure the idx cache is cleaned on deletion, only insert if records are inserted ([#2841](https://github.com/atuinsh/atuin/issues/2841))\n\n\n### Features\n\n- Command chaining ([#2834](https://github.com/atuinsh/atuin/issues/2834))\n- Add info for 'official' plugins ([#2835](https://github.com/atuinsh/atuin/issues/2835))\n- Support multi part commands ([#2836](https://github.com/atuinsh/atuin/issues/2836)) ([#2837](https://github.com/atuinsh/atuin/issues/2837))\n- Add inline_height_shell_up_key_binding option ([#2817](https://github.com/atuinsh/atuin/issues/2817))\n- Add IDX_CACHE_ROLLOUT ([#2850](https://github.com/atuinsh/atuin/issues/2850))\n\n\n### Miscellaneous Tasks\n\n- Update to rust 1.88 ([#2815](https://github.com/atuinsh/atuin/issues/2815))\n\n\n### Nushell\n\n- Fix `get -i` deprecation ([#2829](https://github.com/atuinsh/atuin/issues/2829))\n\n\n## 18.7.1\n\n### Bug Fixes\n\n- Add check for postgresql prefix ([#2825](https://github.com/atuinsh/atuin/issues/2825))\n\n\n### Miscellaneous Tasks\n\n- Update changelog\n\n\n## 18.7.0\n\n### Bug Fixes\n\n- *(api)* Allow trailing slashes in sync_address ([#2760](https://github.com/atuinsh/atuin/issues/2760))\n- *(doctor)* Mention the required ble.sh version ([#2774](https://github.com/atuinsh/atuin/issues/2774))\n- *(search)* Prevent panic on malformed format strings ([#2776](https://github.com/atuinsh/atuin/issues/2776)) ([#2777](https://github.com/atuinsh/atuin/issues/2777))\n- Clarify that HISTFILE, if used, must be exported ([#2758](https://github.com/atuinsh/atuin/issues/2758))\n- Don't print errors in `zsh_autosuggest` helper ([#2780](https://github.com/atuinsh/atuin/issues/2780))\n- `atuin.nu` enchancements ([#2778](https://github.com/atuinsh/atuin/issues/2778))\n- Refuse \"--dupkeep 0\" ([#2807](https://github.com/atuinsh/atuin/issues/2807))\n\n\n### Features\n\n- Add sqlite server support for self-hosting ([#2770](https://github.com/atuinsh/atuin/issues/2770))\n\n\n### Miscellaneous Tasks\n\n- *(ci)* Install toolchain that matches rust-toolchain.toml ([#2759](https://github.com/atuinsh/atuin/issues/2759))\n- Allow setting script DB path ([#2750](https://github.com/atuinsh/atuin/issues/2750))\n\n\n## 18.6.1\n\n### Bug Fixes\n\n- Selection vs render issue ([#2706](https://github.com/atuinsh/atuin/issues/2706))\n\n\n### Features\n\n- *(stats)* Add jj to default common subcommands ([#2708](https://github.com/atuinsh/atuin/issues/2708))\n- Delete duplicate history ([#2697](https://github.com/atuinsh/atuin/issues/2697))\n- Sort `atuin store status` output ([#2719](https://github.com/atuinsh/atuin/issues/2719))\n- Implement KV as a write-through cache ([#2732](https://github.com/atuinsh/atuin/issues/2732))\n\n\n### Miscellaneous Tasks\n\n- Use native github arm64 runner ([#2690](https://github.com/atuinsh/atuin/issues/2690))\n- Fix typos ([#2668](https://github.com/atuinsh/atuin/issues/2668))\n\n\n## 18.5.0\n\n### Bug Fixes\n\n- *(1289)* Clear terminal area if inline ([#2600](https://github.com/atuinsh/atuin/issues/2600))\n- *(bash)* Fix preexec of child Bash session started by enter_accept ([#2558](https://github.com/atuinsh/atuin/issues/2558))\n- *(build)* Change atuin-daemon build script .proto paths ([#2638](https://github.com/atuinsh/atuin/issues/2638))\n- *(kv)* Filter deleted keys from `kv list` ([#2665](https://github.com/atuinsh/atuin/issues/2665))\n- *(stats)* Ignore leading environment variables when calculating stats ([#2659](https://github.com/atuinsh/atuin/issues/2659))\n- *(wrapped)* Fix crash when history is empty ([#2508](https://github.com/atuinsh/atuin/issues/2508))\n- *(zsh)* Fix an error introduced earilier with support for bracketed paste mode ([#2651](https://github.com/atuinsh/atuin/issues/2651))\n- *(zsh)* Avoid calling user-defined widgets when searching for history position ([#2670](https://github.com/atuinsh/atuin/issues/2670))\n- Add .histfile as file to look for when doing atuin import zsh ([#2588](https://github.com/atuinsh/atuin/issues/2588))\n- Panic when invoking delete on empty tui ([#2584](https://github.com/atuinsh/atuin/issues/2584))\n- Sql files checksums ([#2601](https://github.com/atuinsh/atuin/issues/2601))\n- Up binding with fish 4.0 ([#2613](https://github.com/atuinsh/atuin/issues/2613)) ([#2616](https://github.com/atuinsh/atuin/issues/2616))\n- Don't save empty commands ([#2605](https://github.com/atuinsh/atuin/issues/2605))\n- Improve broken symlink error handling ([#2589](https://github.com/atuinsh/atuin/issues/2589))\n- Multiline command does not honour max_preview_height ([#2624](https://github.com/atuinsh/atuin/issues/2624))\n- Typeerror in client sync code ([#2647](https://github.com/atuinsh/atuin/issues/2647))\n- Add redundant clones to clippy and cleanup instances of it ([#2654](https://github.com/atuinsh/atuin/issues/2654))\n- Allow -ve values for timezone ([#2609](https://github.com/atuinsh/atuin/issues/2609))\n- Fish up binding bug ([#2677](https://github.com/atuinsh/atuin/issues/2677))\n- Switch to astral cargo-dist ([#2687](https://github.com/atuinsh/atuin/issues/2687))\n\n\n### Documentation\n\n- Update logo and badges in README for zh-CN ([#2392](https://github.com/atuinsh/atuin/issues/2392))\n\n\n### Features\n\n- *(client)* Update AWS secrets env var handling checks ([#2501](https://github.com/atuinsh/atuin/issues/2501))\n- *(health)* Add health check endpoint at `/healthz` ([#2549](https://github.com/atuinsh/atuin/issues/2549))\n- *(kv)* Add support for 'atuin kv delete' ([#2660](https://github.com/atuinsh/atuin/issues/2660))\n- *(wrapped)* Add more pkg managers ([#2503](https://github.com/atuinsh/atuin/issues/2503))\n- *(zsh)* Try to go to the position in zsh's history ([#1469](https://github.com/atuinsh/atuin/issues/1469))\n- *(zsh)* Re-enable bracketed paste ([#2646](https://github.com/atuinsh/atuin/issues/2646))\n- Add the --print0 option to search ([#2562](https://github.com/atuinsh/atuin/issues/2562))\n- Make new arrow key behavior configurable ([#2606](https://github.com/atuinsh/atuin/issues/2606))\n- Use readline binding for ctrl-a when it is not the prefix ([#2626](https://github.com/atuinsh/atuin/issues/2626))\n- Option to include duplicate commands when printing history commands ([#2407](https://github.com/atuinsh/atuin/issues/2407))\n- Binaries as subcommands ([#2661](https://github.com/atuinsh/atuin/issues/2661))\n- Support storing, syncing and executing scripts ([#2644](https://github.com/atuinsh/atuin/issues/2644))\n- Add 'atuin scripts rm' and 'atuin scripts ls' aliases; allow reading from stdin ([#2680](https://github.com/atuinsh/atuin/issues/2680))\n\n\n### Miscellaneous Tasks\n\n- Remove unneeded dependencies ([#2523](https://github.com/atuinsh/atuin/issues/2523))\n- Update rust toolchain to 1.85 ([#2618](https://github.com/atuinsh/atuin/issues/2618))\n- Align daemon and client sync freq ([#2628](https://github.com/atuinsh/atuin/issues/2628))\n- Migrate to rust 2024 ([#2635](https://github.com/atuinsh/atuin/issues/2635))\n- Show host and user in inspector ([#2634](https://github.com/atuinsh/atuin/issues/2634))\n- Update to rust 1.85.1 ([#2642](https://github.com/atuinsh/atuin/issues/2642))\n- Update to rust 1.86 ([#2666](https://github.com/atuinsh/atuin/issues/2666))\n\n\n### Performance\n\n- Cache `SECRET_PATTERNS`'s `RegexSet` ([#2570](https://github.com/atuinsh/atuin/issues/2570))\n\n\n### Styling\n\n- Avoid calling `unwrap()` when we don't have to ([#2519](https://github.com/atuinsh/atuin/issues/2519))\n\n\n### Build\n\n- *(nix)* Bump `flake.lock` ([#2637](https://github.com/atuinsh/atuin/issues/2637))\n\n\n### Flake.lock\n\n- Update ([#2463](https://github.com/atuinsh/atuin/issues/2463))\n\n\n## 18.4.0\n\n### Bug Fixes\n\n- *(crate)* Add missing description ([#2106](https://github.com/atuinsh/atuin/issues/2106))\n- *(crate)* Add description to daemon crate ([#2107](https://github.com/atuinsh/atuin/issues/2107))\n- *(daemon)* Add context to error when unable to connect ([#2394](https://github.com/atuinsh/atuin/issues/2394))\n- *(deps)* Pin tiny_bip to 1.0.0 until breaking change resolved ([#2412](https://github.com/atuinsh/atuin/issues/2412))\n- *(docker)* Update Dockerfile ([#2369](https://github.com/atuinsh/atuin/issues/2369))\n- *(gui)* Update deps ([#2116](https://github.com/atuinsh/atuin/issues/2116))\n- *(gui)* Add support for checking if the cli is installed on windows ([#2162](https://github.com/atuinsh/atuin/issues/2162))\n- *(gui)* WeekInfo call on Edge ([#2252](https://github.com/atuinsh/atuin/issues/2252))\n- *(gui)* Add \\r for windows (shouldn't effect unix bc they should ignore it) ([#2253](https://github.com/atuinsh/atuin/issues/2253))\n- *(gui)* Terminal resize overflow ([#2285](https://github.com/atuinsh/atuin/issues/2285))\n- *(gui)* Kill child on block stop ([#2288](https://github.com/atuinsh/atuin/issues/2288))\n- *(gui)* Do not hardcode db path ([#2309](https://github.com/atuinsh/atuin/issues/2309))\n- *(gui)* Double return on mac/linux ([#2311](https://github.com/atuinsh/atuin/issues/2311))\n- *(gui)* Cursor positioning on new doc creation ([#2310](https://github.com/atuinsh/atuin/issues/2310))\n- *(gui)* Random ts errors ([#2316](https://github.com/atuinsh/atuin/issues/2316))\n- *(history)* Logic for store_failed=false ([#2284](https://github.com/atuinsh/atuin/issues/2284))\n- *(mail)* Incorrect alias and error logs ([#2346](https://github.com/atuinsh/atuin/issues/2346))\n- *(mail)* Enable correct tls features for postmark client ([#2347](https://github.com/atuinsh/atuin/issues/2347))\n- *(theme)* Restore original colours ([#2339](https://github.com/atuinsh/atuin/issues/2339))\n- *(themes)* Restore default theme, refactor ([#2294](https://github.com/atuinsh/atuin/issues/2294))\n- *(tui)* Press ctrl-a twice should jump to beginning of line ([#2246](https://github.com/atuinsh/atuin/issues/2246))\n- *(tui)* Don't panic when search result is empty and up is pressed ([#2395](https://github.com/atuinsh/atuin/issues/2395))\n- Cargo binstall config ([#2112](https://github.com/atuinsh/atuin/issues/2112))\n- Unitless sync_frequence = 0 not parsed by humantime ([#2154](https://github.com/atuinsh/atuin/issues/2154))\n- Some --help comments didn't show properly ([#2176](https://github.com/atuinsh/atuin/issues/2176))\n- Ensure we cleanup all tables when deleting ([#2191](https://github.com/atuinsh/atuin/issues/2191))\n- Add idx cache unique index ([#2226](https://github.com/atuinsh/atuin/issues/2226))\n- Idx cache inconsistency ([#2231](https://github.com/atuinsh/atuin/issues/2231))\n- Ambiguous column name ([#2232](https://github.com/atuinsh/atuin/issues/2232))\n- Atuin-daemon optional dependency ([#2306](https://github.com/atuinsh/atuin/issues/2306))\n- Windows build error ([#2321](https://github.com/atuinsh/atuin/issues/2321))\n- Codespell config still references the ui ([#2330](https://github.com/atuinsh/atuin/issues/2330))\n- Remove dbg! macro ([#2355](https://github.com/atuinsh/atuin/issues/2355))\n- Disable mail by default, resolve #2404 ([#2405](https://github.com/atuinsh/atuin/issues/2405))\n- Time offset display in `atuin status` ([#2433](https://github.com/atuinsh/atuin/issues/2433))\n- Disable the actuated mirror on the x86 docker builder ([#2443](https://github.com/atuinsh/atuin/issues/2443))\n\n\n### Documentation\n\n- *(README)* Fix broken link ([#2206](https://github.com/atuinsh/atuin/issues/2206))\n- *(gui)* Update README ([#2283](https://github.com/atuinsh/atuin/issues/2283))\n- Streamline readme ([#2203](https://github.com/atuinsh/atuin/issues/2203))\n- Update quickstart install command ([#2205](https://github.com/atuinsh/atuin/issues/2205))\n\n\n### Features\n\n- *(bash/blesh)* Hook into BLE_ONLOAD to resolve loading order issue ([#2234](https://github.com/atuinsh/atuin/issues/2234))\n- *(client)* Add filter mode enablement and ordering configuration ([#2430](https://github.com/atuinsh/atuin/issues/2430))\n- *(daemon)* Follow XDG_RUNTIME_DIR if set ([#2171](https://github.com/atuinsh/atuin/issues/2171))\n- *(gui)* Automatically install and setup the cli/shell ([#2139](https://github.com/atuinsh/atuin/issues/2139))\n- *(gui)* Add activity calendar to the homepage ([#2160](https://github.com/atuinsh/atuin/issues/2160))\n- *(gui)* Cache zustand store in localstorage ([#2168](https://github.com/atuinsh/atuin/issues/2168))\n- *(gui)* Toast with prompt for cli install, rather than auto ([#2173](https://github.com/atuinsh/atuin/issues/2173))\n- *(gui)* Runbooks that run ([#2233](https://github.com/atuinsh/atuin/issues/2233))\n- *(gui)* Use fancy new side nav ([#2243](https://github.com/atuinsh/atuin/issues/2243))\n- *(gui)* Add runbook list, ability to create and delete, sql storage ([#2282](https://github.com/atuinsh/atuin/issues/2282))\n- *(gui)* Background terminals and more ([#2303](https://github.com/atuinsh/atuin/issues/2303))\n- *(gui)* Clean up home page, fix a few bugs ([#2304](https://github.com/atuinsh/atuin/issues/2304))\n- *(gui)* Allow interacting with the embedded terminal ([#2312](https://github.com/atuinsh/atuin/issues/2312))\n- *(gui)* Directory block, re-org of some code ([#2314](https://github.com/atuinsh/atuin/issues/2314))\n- *(gui)* Folder select dialogue for directory block ([#2315](https://github.com/atuinsh/atuin/issues/2315))\n- *(history)* Filter out various environment variables containing potential secrets ([#2174](https://github.com/atuinsh/atuin/issues/2174))\n- *(tui)* Configurable prefix character ([#2157](https://github.com/atuinsh/atuin/issues/2157))\n- *(tui)* Customizable Themes ([#2236](https://github.com/atuinsh/atuin/issues/2236))\n- *(tui)* Fixed preview height option ([#2286](https://github.com/atuinsh/atuin/issues/2286))\n- Use cargo-dist installer from our install script ([#2108](https://github.com/atuinsh/atuin/issues/2108))\n- Add user account verification ([#2190](https://github.com/atuinsh/atuin/issues/2190))\n- Add GitLab PAT to secret patterns ([#2196](https://github.com/atuinsh/atuin/issues/2196))\n- Add several other GitHub access token patterns ([#2200](https://github.com/atuinsh/atuin/issues/2200))\n- Add npm, Netlify and Pulumi tokens to secret patterns ([#2210](https://github.com/atuinsh/atuin/issues/2210))\n- Allow advertising a fake version to clients ([#2228](https://github.com/atuinsh/atuin/issues/2228))\n- Monitor idx cache consistency before switching ([#2229](https://github.com/atuinsh/atuin/issues/2229))\n- Ultracompact Mode (search-only) ([#2357](https://github.com/atuinsh/atuin/issues/2357))\n- Right Arrow to modify selected command ([#2453](https://github.com/atuinsh/atuin/issues/2453))\n- Provide additional clarity around key management ([#2467](https://github.com/atuinsh/atuin/issues/2467))\n- Add `atuin wrapped` ([#2493](https://github.com/atuinsh/atuin/issues/2493))\n\n\n### Miscellaneous Tasks\n\n- *(build)* Compile protobufs with protox ([#2122](https://github.com/atuinsh/atuin/issues/2122))\n- *(ci)* Do not run current ci for ui ([#2189](https://github.com/atuinsh/atuin/issues/2189))\n- *(ci)* Codespell again ([#2332](https://github.com/atuinsh/atuin/issues/2332))\n- *(deps-dev)* Bump @tauri-apps/cli in /ui ([#2135](https://github.com/atuinsh/atuin/issues/2135))\n- *(deps-dev)* Bump vite from 5.2.13 to 5.3.1 in /ui ([#2150](https://github.com/atuinsh/atuin/issues/2150))\n- *(deps-dev)* Bump @tauri-apps/cli in /ui ([#2277](https://github.com/atuinsh/atuin/issues/2277))\n- *(deps-dev)* Bump tailwindcss from 3.4.4 to 3.4.6 in /ui ([#2301](https://github.com/atuinsh/atuin/issues/2301))\n- *(install)* Use posix sh, not bash ([#2204](https://github.com/atuinsh/atuin/issues/2204))\n- *(nix)* De-couple atuin nix build from nixpkgs rustc version ([#2123](https://github.com/atuinsh/atuin/issues/2123))\n- Add installer e2e tests ([#2110](https://github.com/atuinsh/atuin/issues/2110))\n- Remove unnecessary proto import ([#2120](https://github.com/atuinsh/atuin/issues/2120))\n- Update to rust 1.78\n- Add audit config, ignore RUSTSEC-2023-0071 ([#2126](https://github.com/atuinsh/atuin/issues/2126))\n- Setup dependabot for the ui ([#2128](https://github.com/atuinsh/atuin/issues/2128))\n- Cargo and pnpm update ([#2127](https://github.com/atuinsh/atuin/issues/2127))\n- Update to rust 1.79 ([#2138](https://github.com/atuinsh/atuin/issues/2138))\n- Update to cargo-dist 0.16, enable attestations ([#2156](https://github.com/atuinsh/atuin/issues/2156))\n- Do not use package managers in installer ([#2201](https://github.com/atuinsh/atuin/issues/2201))\n- Enable record sync by default ([#2255](https://github.com/atuinsh/atuin/issues/2255))\n- Remove ui directory ([#2329](https://github.com/atuinsh/atuin/issues/2329))\n- Update to rust 1.80 ([#2344](https://github.com/atuinsh/atuin/issues/2344))\n- Update rust to `1.80.1` ([#2362](https://github.com/atuinsh/atuin/issues/2362))\n- Enable inline height and compact by default ([#2249](https://github.com/atuinsh/atuin/issues/2249))\n- Update to rust 1.82 ([#2432](https://github.com/atuinsh/atuin/issues/2432))\n- Update cargo-dist ([#2471](https://github.com/atuinsh/atuin/issues/2471))\n\n\n### Performance\n\n- *(search)* Benchmark smart sort ([#2202](https://github.com/atuinsh/atuin/issues/2202))\n- Create idx cache table ([#2140](https://github.com/atuinsh/atuin/issues/2140))\n- Write to the idx cache ([#2225](https://github.com/atuinsh/atuin/issues/2225))\n\n\n### Testing\n\n- Add env ATUIN_TEST_LOCAL_TIMEOUT to control test timeout of SQLite ([#2337](https://github.com/atuinsh/atuin/issues/2337))\n\n\n### Flake.lock\n\n- Update ([#2213](https://github.com/atuinsh/atuin/issues/2213))\n- Update ([#2378](https://github.com/atuinsh/atuin/issues/2378))\n- Update ([#2402](https://github.com/atuinsh/atuin/issues/2402))\n\n\n## 18.3.0\n\n### Bug Fixes\n\n- *(bash)* Fix a workaround for bash-5.2 keybindings ([#2060](https://github.com/atuinsh/atuin/issues/2060))\n- *(ci)* Release workflow ([#1978](https://github.com/atuinsh/atuin/issues/1978))\n- *(client)* Better error reporting on login/registration ([#2076](https://github.com/atuinsh/atuin/issues/2076))\n- *(config)* Add quotes for strategy value in comment ([#1993](https://github.com/atuinsh/atuin/issues/1993))\n- *(daemon)* Do not try to sync if logged out ([#2037](https://github.com/atuinsh/atuin/issues/2037))\n- *(deps)* Replace parse_duration with humantime ([#2074](https://github.com/atuinsh/atuin/issues/2074))\n- *(dotfiles)* Alias import with init output ([#1970](https://github.com/atuinsh/atuin/issues/1970))\n- *(dotfiles)* Fish alias import ([#1972](https://github.com/atuinsh/atuin/issues/1972))\n- *(dotfiles)* More fish alias import ([#1974](https://github.com/atuinsh/atuin/issues/1974))\n- *(dotfiles)* Unquote aliases before quoting ([#1976](https://github.com/atuinsh/atuin/issues/1976))\n- *(dotfiles)* Allow clearing aliases, disable import ([#1995](https://github.com/atuinsh/atuin/issues/1995))\n- *(stats)* Generation for commands starting with a pipe ([#2058](https://github.com/atuinsh/atuin/issues/2058))\n- *(ui)* Handle being logged out gracefully ([#2052](https://github.com/atuinsh/atuin/issues/2052))\n- *(ui)* Fix mistake in last pr ([#2053](https://github.com/atuinsh/atuin/issues/2053))\n- Support not-mac for default shell ([#1960](https://github.com/atuinsh/atuin/issues/1960))\n- Adapt help to `enter_accept` config ([#2001](https://github.com/atuinsh/atuin/issues/2001))\n- Add protobuf compiler to docker image ([#2009](https://github.com/atuinsh/atuin/issues/2009))\n- Add incremental rebuild to daemon loop ([#2010](https://github.com/atuinsh/atuin/issues/2010))\n- Alias enable/enabled in settings ([#2021](https://github.com/atuinsh/atuin/issues/2021))\n- Bogus error message wording ([#1283](https://github.com/atuinsh/atuin/issues/1283))\n- Save sync time in daemon ([#2029](https://github.com/atuinsh/atuin/issues/2029))\n- Redact password in database URI when logging ([#2032](https://github.com/atuinsh/atuin/issues/2032))\n- Save sync time in daemon ([#2051](https://github.com/atuinsh/atuin/issues/2051))\n- Replace serde_yaml::to_string with serde_json::to_string_yaml ([#2087](https://github.com/atuinsh/atuin/issues/2087))\n\n\n### Documentation\n\n- Fix \"From source\" `cd` command ([#1973](https://github.com/atuinsh/atuin/issues/1973))\n- Add docs for store subcommand ([#2097](https://github.com/atuinsh/atuin/issues/2097))\n\n\n### Features\n\n- *(daemon)* Add support for daemon on windows ([#2014](https://github.com/atuinsh/atuin/issues/2014))\n- *(doctor)* Detect active preexec framework ([#1955](https://github.com/atuinsh/atuin/issues/1955))\n- *(doctor)* Report sqlite version ([#2075](https://github.com/atuinsh/atuin/issues/2075))\n- *(dotfiles)* Support syncing shell/env vars ([#1977](https://github.com/atuinsh/atuin/issues/1977))\n- *(gui)* Work on home page, sort state ([#1956](https://github.com/atuinsh/atuin/issues/1956))\n- *(history)* Create atuin-history, add stats to it ([#1990](https://github.com/atuinsh/atuin/issues/1990))\n- *(install)* Add Tuxedo OS ([#2018](https://github.com/atuinsh/atuin/issues/2018))\n- *(server)* Add me endpoint ([#1954](https://github.com/atuinsh/atuin/issues/1954))\n- *(ui)* Scroll history infinitely ([#1999](https://github.com/atuinsh/atuin/issues/1999))\n- *(ui)* Add history explore ([#2022](https://github.com/atuinsh/atuin/issues/2022))\n- *(ui)* Use correct username on welcome screen ([#2050](https://github.com/atuinsh/atuin/issues/2050))\n- *(ui)* Add login/register dialog ([#2056](https://github.com/atuinsh/atuin/issues/2056))\n- *(ui)* Setup single-instance ([#2093](https://github.com/atuinsh/atuin/issues/2093))\n- *(ui/dotfiles)* Add vars ([#1989](https://github.com/atuinsh/atuin/issues/1989))\n- Allow ignoring failed commands ([#1957](https://github.com/atuinsh/atuin/issues/1957))\n- Show preview auto ([#1804](https://github.com/atuinsh/atuin/issues/1804))\n- Add background daemon ([#2006](https://github.com/atuinsh/atuin/issues/2006))\n- Support importing from replxx history files ([#2024](https://github.com/atuinsh/atuin/issues/2024))\n- Support systemd socket activation for daemon ([#2039](https://github.com/atuinsh/atuin/issues/2039))\n\n\n### Miscellaneous Tasks\n\n- *(ci)* Don't run \"Update Nix Deps\" CI on forks ([#2070](https://github.com/atuinsh/atuin/issues/2070))\n- *(codespell)* Ignore CODE_OF_CONDUCT ([#2044](https://github.com/atuinsh/atuin/issues/2044))\n- *(install)* Log cargo and rustc version ([#2068](https://github.com/atuinsh/atuin/issues/2068))\n- *(release)* V18.3.0-prerelease.1 ([#2090](https://github.com/atuinsh/atuin/issues/2090))\n- Move crates into crates/ dir ([#1958](https://github.com/atuinsh/atuin/issues/1958))\n- Fix atuin crate readme ([#1959](https://github.com/atuinsh/atuin/issues/1959))\n- Add some more logging to handlers ([#1971](https://github.com/atuinsh/atuin/issues/1971))\n- Add some more debug logs ([#1979](https://github.com/atuinsh/atuin/issues/1979))\n- Clarify default config file ([#2026](https://github.com/atuinsh/atuin/issues/2026))\n- Handle rate limited responses ([#2057](https://github.com/atuinsh/atuin/issues/2057))\n- Add Systemd config for self-hosted server ([#1879](https://github.com/atuinsh/atuin/issues/1879))\n- Switch to cargo dist for releases ([#2085](https://github.com/atuinsh/atuin/issues/2085))\n- Update email, gitignore, tweak ui ([#2094](https://github.com/atuinsh/atuin/issues/2094))\n- Show scope in changelog ([#2102](https://github.com/atuinsh/atuin/issues/2102))\n\n\n### Performance\n\n- *(nushell)* Use version.(major|minor|patch) if available ([#1963](https://github.com/atuinsh/atuin/issues/1963))\n- Only open the database for commands if strictly required ([#2043](https://github.com/atuinsh/atuin/issues/2043))\n\n\n### Refactor\n\n- Preview_auto to use enum and different option ([#1991](https://github.com/atuinsh/atuin/issues/1991))\n\n\n## 18.2.0\n\n### Bug Fixes\n\n- *(bash)* Do not use \"return\" to cancel initialization ([#1928](https://github.com/atuinsh/atuin/issues/1928))\n- *(crate)* Add missing description ([#1861](https://github.com/atuinsh/atuin/issues/1861))\n- *(doctor)* Detect preexec plugin using env ATUIN_PREEXEC_BACKEND  ([#1856](https://github.com/atuinsh/atuin/issues/1856))\n- *(install)* Install script echo ([#1899](https://github.com/atuinsh/atuin/issues/1899))\n- *(nu)* Update atuin.nu to resolve 0.92 deprecation ([#1913](https://github.com/atuinsh/atuin/issues/1913))\n- *(search)* Allow empty search ([#1866](https://github.com/atuinsh/atuin/issues/1866))\n- *(search)* Case insensitive hostname filtering ([#1883](https://github.com/atuinsh/atuin/issues/1883))\n- Pass search query in via env ([#1865](https://github.com/atuinsh/atuin/issues/1865))\n- Pass search query in via env for *Nushell* ([#1874](https://github.com/atuinsh/atuin/issues/1874))\n- Report non-decodable errors correctly ([#1915](https://github.com/atuinsh/atuin/issues/1915))\n- Use spawn_blocking for file access during async context ([#1936](https://github.com/atuinsh/atuin/issues/1936))\n\n\n### Documentation\n\n- *(bash-preexec)* Describe the limitation of missing commands ([#1937](https://github.com/atuinsh/atuin/issues/1937))\n- Add security contact ([#1867](https://github.com/atuinsh/atuin/issues/1867))\n- Add install instructions for cave/exherbo linux in README.md ([#1927](https://github.com/atuinsh/atuin/issues/1927))\n- Add missing cli help text ([#1945](https://github.com/atuinsh/atuin/issues/1945))\n\n\n### Features\n\n- *(bash/blesh)* Use _ble_exec_time_ata for duration even in bash < 5 ([#1940](https://github.com/atuinsh/atuin/issues/1940))\n- *(dotfiles)* Add alias import ([#1938](https://github.com/atuinsh/atuin/issues/1938))\n- *(gui)* Add base structure ([#1935](https://github.com/atuinsh/atuin/issues/1935))\n- *(install)* Update install.sh to support KDE Neon ([#1908](https://github.com/atuinsh/atuin/issues/1908))\n- *(search)* Process [C-h] and [C-?] as representations of backspace ([#1857](https://github.com/atuinsh/atuin/issues/1857))\n- *(search)* Allow specifying search query as an env var ([#1863](https://github.com/atuinsh/atuin/issues/1863))\n- *(search)* Add better search scoring ([#1885](https://github.com/atuinsh/atuin/issues/1885))\n- *(server)* Check PG version before running migrations ([#1868](https://github.com/atuinsh/atuin/issues/1868))\n- Add atuin prefix binding ([#1875](https://github.com/atuinsh/atuin/issues/1875))\n- Sync v2 default for new installs ([#1914](https://github.com/atuinsh/atuin/issues/1914))\n- Add 'ctrl-a a' to jump to beginning of line ([#1917](https://github.com/atuinsh/atuin/issues/1917))\n- Prevents stderr from going to the screen ([#1933](https://github.com/atuinsh/atuin/issues/1933))\n\n\n### Miscellaneous Tasks\n\n- *(ci)* Add codespell support (config, workflow) and make it fix some typos ([#1916](https://github.com/atuinsh/atuin/issues/1916))\n- *(gui)* Cargo update ([#1943](https://github.com/atuinsh/atuin/issues/1943))\n- Add issue form ([#1871](https://github.com/atuinsh/atuin/issues/1871))\n- Require atuin doctor in issue form ([#1872](https://github.com/atuinsh/atuin/issues/1872))\n- Add section to issue form ([#1873](https://github.com/atuinsh/atuin/issues/1873))\n\n\n### Performance\n\n- *(dotfiles)* Cache aliases and read straight from file ([#1918](https://github.com/atuinsh/atuin/issues/1918))\n\n\n## 18.1.0\n\n### Bug Fixes\n\n- *(bash)* Rework #1509 to recover from the preexec failure ([#1729](https://github.com/atuinsh/atuin/issues/1729))\n- *(build)* Make atuin compile on non-win/mac/linux platforms ([#1825](https://github.com/atuinsh/atuin/issues/1825))\n- *(client)* No panic on empty inspector ([#1768](https://github.com/atuinsh/atuin/issues/1768))\n- *(doctor)* Use a different method to detect env vars ([#1819](https://github.com/atuinsh/atuin/issues/1819))\n- *(dotfiles)* Use latest client ([#1859](https://github.com/atuinsh/atuin/issues/1859))\n- *(import/zsh-histdb)* Missing or wrong fields ([#1740](https://github.com/atuinsh/atuin/issues/1740))\n- *(nix)* Set meta.mainProgram in the package ([#1823](https://github.com/atuinsh/atuin/issues/1823))\n- *(nushell)* Readd up-arrow keybinding, now with menu handling ([#1770](https://github.com/atuinsh/atuin/issues/1770))\n- *(regex)* Disable regex error logs ([#1806](https://github.com/atuinsh/atuin/issues/1806))\n- *(stats)* Enable multiple command stats to be shown using unicode_segmentation ([#1739](https://github.com/atuinsh/atuin/issues/1739))\n- *(store-init)* Re-sync after running auto store init ([#1834](https://github.com/atuinsh/atuin/issues/1834))\n- *(sync)* Check store length after sync, not before ([#1805](https://github.com/atuinsh/atuin/issues/1805))\n- *(sync)* Record size limiter ([#1827](https://github.com/atuinsh/atuin/issues/1827))\n- *(tz)* Attempt to fix timezone reading ([#1810](https://github.com/atuinsh/atuin/issues/1810))\n- *(ui)* Don't preserve for empty space ([#1712](https://github.com/atuinsh/atuin/issues/1712))\n- *(xonsh)* Add xonsh to auto import, respect $HISTFILE in xonsh import, and fix issue with up-arrow keybinding in xonsh ([#1711](https://github.com/atuinsh/atuin/issues/1711))\n- Fish init ([#1725](https://github.com/atuinsh/atuin/issues/1725))\n- Typo ([#1741](https://github.com/atuinsh/atuin/issues/1741))\n- Check session file exists for status command ([#1756](https://github.com/atuinsh/atuin/issues/1756))\n- Ensure sync time is saved for sync v2 ([#1758](https://github.com/atuinsh/atuin/issues/1758))\n- Missing characters in preview ([#1803](https://github.com/atuinsh/atuin/issues/1803))\n- Doctor shell wording ([#1858](https://github.com/atuinsh/atuin/issues/1858))\n\n\n### Documentation\n\n- Minor formatting updates to the default config.toml ([#1689](https://github.com/atuinsh/atuin/issues/1689))\n- Update docker compose ([#1818](https://github.com/atuinsh/atuin/issues/1818))\n- Use db name env variable also in uri ([#1840](https://github.com/atuinsh/atuin/issues/1840))\n\n\n### Features\n\n- *(client)* Add config option keys.scroll_exits ([#1744](https://github.com/atuinsh/atuin/issues/1744))\n- *(dotfiles)* Add enable setting to dotfiles, disable by default ([#1829](https://github.com/atuinsh/atuin/issues/1829))\n- *(nix)* Add update action ([#1779](https://github.com/atuinsh/atuin/issues/1779))\n- *(nu)* Return early if history is disabled ([#1807](https://github.com/atuinsh/atuin/issues/1807))\n- *(nushell)* Add nushell completion generation ([#1791](https://github.com/atuinsh/atuin/issues/1791))\n- *(search)* Process Ctrl+m for kitty keyboard protocol ([#1720](https://github.com/atuinsh/atuin/issues/1720))\n- *(stats)* Normalize formatting of default config, suggest nix ([#1764](https://github.com/atuinsh/atuin/issues/1764))\n- *(stats)* Add linux sysadmin commands to common_subcommands ([#1784](https://github.com/atuinsh/atuin/issues/1784))\n- *(ui)* Add config setting for showing tabs ([#1755](https://github.com/atuinsh/atuin/issues/1755))\n- Use ATUIN_TEST_SQLITE_STORE_TIMEOUT to specify test timeout of SQLite store ([#1703](https://github.com/atuinsh/atuin/issues/1703))\n- Add 'a', 'A', 'h', and 'l' bindings to vim-normal mode ([#1697](https://github.com/atuinsh/atuin/issues/1697))\n- Add xonsh history import ([#1678](https://github.com/atuinsh/atuin/issues/1678))\n- Add 'ignored_commands' option to stats ([#1722](https://github.com/atuinsh/atuin/issues/1722))\n- Support syncing aliases ([#1721](https://github.com/atuinsh/atuin/issues/1721))\n- Change fulltext to do multi substring match ([#1660](https://github.com/atuinsh/atuin/issues/1660))\n- Add history prune subcommand ([#1743](https://github.com/atuinsh/atuin/issues/1743))\n- Add alias feedback and list command ([#1747](https://github.com/atuinsh/atuin/issues/1747))\n- Add PHP package manager \"composer\" to list of default common subcommands ([#1757](https://github.com/atuinsh/atuin/issues/1757))\n- Add '/', '?', and 'I' bindings to vim-normal mode ([#1760](https://github.com/atuinsh/atuin/issues/1760))\n- Add `CTRL+[` binding as `<Esc>` alias ([#1787](https://github.com/atuinsh/atuin/issues/1787))\n- Add atuin doctor ([#1796](https://github.com/atuinsh/atuin/issues/1796))\n- Add checks for common setup issues ([#1799](https://github.com/atuinsh/atuin/issues/1799))\n- Support regex with r/.../ syntax ([#1745](https://github.com/atuinsh/atuin/issues/1745))\n- Guard against ancient versions of bash where this does not work. ([#1794](https://github.com/atuinsh/atuin/issues/1794))\n- Add automatic history store init ([#1831](https://github.com/atuinsh/atuin/issues/1831))\n- Adds info command to show env vars and config files ([#1841](https://github.com/atuinsh/atuin/issues/1841))\n\n\n### Miscellaneous Tasks\n\n- *(ci)* Add cross-compile job for illumos ([#1830](https://github.com/atuinsh/atuin/issues/1830))\n- *(ci)* Setup nextest ([#1848](https://github.com/atuinsh/atuin/issues/1848))\n- Do not show history table stats when using records ([#1835](https://github.com/atuinsh/atuin/issues/1835))\n\n\n### Performance\n\n- Optimize history init-store ([#1691](https://github.com/atuinsh/atuin/issues/1691))\n\n\n### Refactor\n\n- *(alias)* Clarify operation result for working with aliases ([#1748](https://github.com/atuinsh/atuin/issues/1748))\n- *(nushell)* Update `commandline` syntax, closes #1733 ([#1735](https://github.com/atuinsh/atuin/issues/1735))\n- Rename atuin-config to atuin-dotfiles ([#1817](https://github.com/atuinsh/atuin/issues/1817))\n\n\n## 18.0.1\n\n### Bug Fixes\n\n- Reorder the exit of enhanced keyboard mode ([#1694](https://github.com/atuinsh/atuin/issues/1694))\n\n\n## 18.0.0\n\n### Bug Fixes\n\n- *(bash)* Avoid unexpected `atuin history start` for keybindings ([#1509](https://github.com/atuinsh/atuin/issues/1509))\n- *(bash)* Prevent input to be interpreted as options for blesh auto-complete ([#1511](https://github.com/atuinsh/atuin/issues/1511))\n- *(bash)* Work around custom IFS ([#1514](https://github.com/atuinsh/atuin/issues/1514))\n- *(bash)* Fix and improve the keybinding to `up` ([#1515](https://github.com/atuinsh/atuin/issues/1515))\n- *(bash)* Work around bash < 4 and introduce initialization guards ([#1533](https://github.com/atuinsh/atuin/issues/1533))\n- *(bash)* Strip control chars generated by `\\[\\]` in PS1 with bash-preexec ([#1620](https://github.com/atuinsh/atuin/issues/1620))\n- *(bash/preexec)* Erase the prompt last line before Bash renders it\n- *(bash/preexec)* Erase the previous prompt before overwriting\n- *(bash/preexec)* Support termcap names for tput ([#1670](https://github.com/atuinsh/atuin/issues/1670))\n- *(docs)* Update repo url in CONTRIBUTING.md ([#1594](https://github.com/atuinsh/atuin/issues/1594))\n- *(fish)* Integration on older fishes ([#1563](https://github.com/atuinsh/atuin/issues/1563))\n- *(perm)* Set umask 077 ([#1554](https://github.com/atuinsh/atuin/issues/1554))\n- *(search)* Fix invisible tab title ([#1560](https://github.com/atuinsh/atuin/issues/1560))\n- *(shell)* Fix incorrect timing of child shells ([#1510](https://github.com/atuinsh/atuin/issues/1510))\n- *(sync)* Save sync time when it starts, not ends ([#1573](https://github.com/atuinsh/atuin/issues/1573))\n- *(tests)* Add Settings::utc() for utc settings ([#1677](https://github.com/atuinsh/atuin/issues/1677))\n- *(tui)* Dedupe was removing history ([#1610](https://github.com/atuinsh/atuin/issues/1610))\n- *(windows)* Disables unix specific stuff for windows ([#1557](https://github.com/atuinsh/atuin/issues/1557))\n- Prevent input to be interpreted as options for zsh autosuggestions ([#1506](https://github.com/atuinsh/atuin/issues/1506))\n- Disable musl deb building ([#1525](https://github.com/atuinsh/atuin/issues/1525))\n- Shorten text, use ctrl-o for inspector ([#1561](https://github.com/atuinsh/atuin/issues/1561))\n- Print literal control characters to non terminals ([#1586](https://github.com/atuinsh/atuin/issues/1586))\n- Escape control characters in command preview ([#1588](https://github.com/atuinsh/atuin/issues/1588))\n- Use existing db querying for history list ([#1589](https://github.com/atuinsh/atuin/issues/1589))\n- Add acquire timeout to sqlite database connection ([#1590](https://github.com/atuinsh/atuin/issues/1590))\n- Only escape control characters when writing to terminal ([#1593](https://github.com/atuinsh/atuin/issues/1593))\n- Check for format errors when printing history ([#1623](https://github.com/atuinsh/atuin/issues/1623))\n- Skip padding time if it will overflow the allowed prefix length ([#1630](https://github.com/atuinsh/atuin/issues/1630))\n- Never overwrite the key ([#1657](https://github.com/atuinsh/atuin/issues/1657))\n- Set durability for sqlite to recommended settings ([#1667](https://github.com/atuinsh/atuin/issues/1667))\n- Correct download list for incremental builds ([#1672](https://github.com/atuinsh/atuin/issues/1672))\n\n\n### Documentation\n\n- *(README)* Clarify prerequisites for Bash ([#1686](https://github.com/atuinsh/atuin/issues/1686))\n- *(readme)* Add repology badge ([#1494](https://github.com/atuinsh/atuin/issues/1494))\n- Add forum link to contributing ([#1498](https://github.com/atuinsh/atuin/issues/1498))\n- Refer to image with multi-arch support ([#1513](https://github.com/atuinsh/atuin/issues/1513))\n- Remove activity graph\n- Fix `Destination file already exists` in Nushell ([#1530](https://github.com/atuinsh/atuin/issues/1530))\n- Clarify enter/tab usage ([#1538](https://github.com/atuinsh/atuin/issues/1538))\n- Improve style ([#1537](https://github.com/atuinsh/atuin/issues/1537))\n- Remove old docusaurus ([#1581](https://github.com/atuinsh/atuin/issues/1581))\n- Mention environment variables for custom paths ([#1614](https://github.com/atuinsh/atuin/issues/1614))\n- Create pull_request_template.md ([#1632](https://github.com/atuinsh/atuin/issues/1632))\n- Update CONTRIBUTING.md ([#1633](https://github.com/atuinsh/atuin/issues/1633))\n\n\n### Features\n\n- *(bash)* Support high-resolution timing even without ble.sh ([#1534](https://github.com/atuinsh/atuin/issues/1534))\n- *(search)* Introduce keymap-dependent vim-mode ([#1570](https://github.com/atuinsh/atuin/issues/1570))\n- *(search)* Make cursor style configurable ([#1595](https://github.com/atuinsh/atuin/issues/1595))\n- *(shell)* Bind the Atuin search to \"/\" in vi-normal mode ([#1629](https://github.com/atuinsh/atuin/issues/1629))\n  - **BREAKING**: bind the Atuin search to \"/\" in vi-normal mode ([#1629](https://github.com/atuinsh/atuin/issues/1629))\n- *(ui)* Add redraw ([#1519](https://github.com/atuinsh/atuin/issues/1519))\n- *(ui)* Vim mode ([#1553](https://github.com/atuinsh/atuin/issues/1553))\n- *(ui)* When in vim-normal mode apply an alternative highlighting to the selected line ([#1574](https://github.com/atuinsh/atuin/issues/1574))\n- *(zsh)* Update widget names ([#1631](https://github.com/atuinsh/atuin/issues/1631))\n- Enable enhanced keyboard mode ([#1505](https://github.com/atuinsh/atuin/issues/1505))\n- Rework record sync for improved reliability ([#1478](https://github.com/atuinsh/atuin/issues/1478))\n- Include atuin login in secret patterns ([#1518](https://github.com/atuinsh/atuin/issues/1518))\n- Make it clear what you are registering for ([#1523](https://github.com/atuinsh/atuin/issues/1523))\n- Add extended help ([#1540](https://github.com/atuinsh/atuin/issues/1540))\n- Add interactive command inspector ([#1296](https://github.com/atuinsh/atuin/issues/1296))\n- Add better error handling for sync ([#1572](https://github.com/atuinsh/atuin/issues/1572))\n- Add history rebuild ([#1575](https://github.com/atuinsh/atuin/issues/1575))\n- Make deleting from the UI work with record store sync ([#1580](https://github.com/atuinsh/atuin/issues/1580))\n- Add metrics counter for records downloaded ([#1584](https://github.com/atuinsh/atuin/issues/1584))\n- Make store init idempotent ([#1609](https://github.com/atuinsh/atuin/issues/1609))\n- Don't stop with invalid key ([#1612](https://github.com/atuinsh/atuin/issues/1612))\n- Add registered and deleted metrics ([#1622](https://github.com/atuinsh/atuin/issues/1622))\n- Make history list format configurable ([#1638](https://github.com/atuinsh/atuin/issues/1638))\n- Add change-password command & support on server ([#1615](https://github.com/atuinsh/atuin/issues/1615))\n- Automatically init history store when record sync is enabled ([#1634](https://github.com/atuinsh/atuin/issues/1634))\n- Add store push ([#1649](https://github.com/atuinsh/atuin/issues/1649))\n- Reencrypt/rekey local store ([#1662](https://github.com/atuinsh/atuin/issues/1662))\n- Add prefers_reduced_motion flag ([#1645](https://github.com/atuinsh/atuin/issues/1645))\n- Add verify command to local store\n- Add store purge command\n- Failure to decrypt history = failure to sync\n- Add `store push --force`\n- Add `store pull`\n- Disable auto record store init ([#1671](https://github.com/atuinsh/atuin/issues/1671))\n- Add progress bars to sync and store init ([#1684](https://github.com/atuinsh/atuin/issues/1684))\n\n\n### Miscellaneous Tasks\n\n- *(ci)* Use github m1 for release builds ([#1658](https://github.com/atuinsh/atuin/issues/1658))\n- *(ci)* Re-enable test cache, add separate check step ([#1663](https://github.com/atuinsh/atuin/issues/1663))\n- *(ci)* Run rust build/test/check on 3 platforms ([#1675](https://github.com/atuinsh/atuin/issues/1675))\n- Remove the teapot response ([#1496](https://github.com/atuinsh/atuin/issues/1496))\n- Schema cleanup ([#1522](https://github.com/atuinsh/atuin/issues/1522))\n- Update funding ([#1543](https://github.com/atuinsh/atuin/issues/1543))\n- Make clipboard dep optional as a feature ([#1558](https://github.com/atuinsh/atuin/issues/1558))\n- Add feature to allow always disable check update ([#1628](https://github.com/atuinsh/atuin/issues/1628))\n- Use resolver 2, update editions + cargo ([#1635](https://github.com/atuinsh/atuin/issues/1635))\n- Disable nix tests ([#1646](https://github.com/atuinsh/atuin/issues/1646))\n- Set ATUIN_ variables for development in devshell ([#1653](https://github.com/atuinsh/atuin/issues/1653))\n\n\n### Refactor\n\n- *(search)* Refactor vim mode ([#1559](https://github.com/atuinsh/atuin/issues/1559))\n- *(search)* Refactor handling of key inputs ([#1606](https://github.com/atuinsh/atuin/issues/1606))\n- *(shell)* Refactor and localize `HISTORY => __atuin_output` ([#1535](https://github.com/atuinsh/atuin/issues/1535))\n- Use enum instead of magic numbers ([#1499](https://github.com/atuinsh/atuin/issues/1499))\n- String -> HistoryId ([#1512](https://github.com/atuinsh/atuin/issues/1512))\n\n\n### Styling\n\n- *(bash)* Use consistent coding style ([#1528](https://github.com/atuinsh/atuin/issues/1528))\n\n\n### Testing\n\n- Add multi-user integration tests ([#1648](https://github.com/atuinsh/atuin/issues/1648))\n\n\n### Stats\n\n- Misc improvements ([#1613](https://github.com/atuinsh/atuin/issues/1613))\n\n\n## 17.2.1\n\n### Bug Fixes\n\n- *(server)* Typo with default config ([#1493](https://github.com/atuinsh/atuin/issues/1493))\n\n\n## 17.2.0\n\n### Bug Fixes\n\n- *(bash)* Fix loss of the last output line with enter_accept ([#1463](https://github.com/atuinsh/atuin/issues/1463))\n- *(bash)* Improve the support for `enter_accept` with `ble.sh` ([#1465](https://github.com/atuinsh/atuin/issues/1465))\n- *(bash)* Fix small issues of `enter_accept` for the plain Bash ([#1467](https://github.com/atuinsh/atuin/issues/1467))\n- *(bash)* Fix error by the use of ${PS1@P} in bash < 4.4 ([#1488](https://github.com/atuinsh/atuin/issues/1488))\n- *(bash,zsh)* Fix quirks on search cancel ([#1483](https://github.com/atuinsh/atuin/issues/1483))\n- *(clippy)* Ignore struct_field_names ([#1466](https://github.com/atuinsh/atuin/issues/1466))\n- *(docs)* Fix typo ([#1439](https://github.com/atuinsh/atuin/issues/1439))\n- *(docs)* Discord link expired\n- *(history)* Disallow deletion if the '--limit' flag is present ([#1436](https://github.com/atuinsh/atuin/issues/1436))\n- *(import/zsh)* Zsh use a special format to escape some characters ([#1490](https://github.com/atuinsh/atuin/issues/1490))\n- *(install)* Discord broken link\n- *(shell)* Respect ZSH's $ZDOTDIR environment variable ([#1441](https://github.com/atuinsh/atuin/issues/1441))\n- *(stats)* Don't require all fields under [stats] ([#1437](https://github.com/atuinsh/atuin/issues/1437))\n- *(stats)* Time now_local not working\n- *(zsh)* Zsh_autosuggest_strategy for no-unset environment ([#1486](https://github.com/atuinsh/atuin/issues/1486))\n\n\n### Documentation\n\n- *(readme)* Add actuated linkback\n- *(readme)* Fix light/dark mode logo\n- *(readme)* Use picture element for logo\n- Add link to forum\n- Align setup links in docs and readme ([#1446](https://github.com/atuinsh/atuin/issues/1446))\n- Add Void Linux install instruction ([#1445](https://github.com/atuinsh/atuin/issues/1445))\n- Add fish install script ([#1447](https://github.com/atuinsh/atuin/issues/1447))\n- Correct link\n- Add docs for zsh-autosuggestion integration ([#1480](https://github.com/atuinsh/atuin/issues/1480))\n- Remove stray character from README\n- Update logo ([#1481](https://github.com/atuinsh/atuin/issues/1481))\n\n\n### Features\n\n- *(bash)* Provide auto-complete source for ble.sh ([#1487](https://github.com/atuinsh/atuin/issues/1487))\n- *(shell)* Support high-resolution duration if available ([#1484](https://github.com/atuinsh/atuin/issues/1484))\n- Add semver checking to client requests ([#1456](https://github.com/atuinsh/atuin/issues/1456))\n- Add TLS to atuin-server ([#1457](https://github.com/atuinsh/atuin/issues/1457))\n- Integrate with zsh-autosuggestions ([#1479](https://github.com/atuinsh/atuin/issues/1479))\n\n\n### Miscellaneous Tasks\n\n- *(repo)* Remove issue config ([#1433](https://github.com/atuinsh/atuin/issues/1433))\n- Remove issue template ([#1444](https://github.com/atuinsh/atuin/issues/1444))\n\n\n### Refactor\n\n- *(bash)* Factorize `__atuin_accept_line` ([#1476](https://github.com/atuinsh/atuin/issues/1476))\n- *(bash)* Refactor and optimize `__atuin_accept_line` ([#1482](https://github.com/atuinsh/atuin/issues/1482))\n\n\n## 17.1.0\n\n### Bug Fixes\n\n- *(fish)* Clean up the fish script options ([#1370](https://github.com/atuinsh/atuin/issues/1370))\n- *(fish)* Use fish builtins for `enter_accept` ([#1373](https://github.com/atuinsh/atuin/issues/1373))\n- *(fish)* Accept multiline commands ([#1418](https://github.com/atuinsh/atuin/issues/1418))\n- *(nix)* Add Appkit to the package build ([#1358](https://github.com/atuinsh/atuin/issues/1358))\n- *(zsh)* Bind in the most popular modes ([#1360](https://github.com/atuinsh/atuin/issues/1360))\n- *(zsh)* Only trigger up-arrow on first line ([#1359](https://github.com/atuinsh/atuin/issues/1359))\n- Initial list of history in workspace mode ([#1356](https://github.com/atuinsh/atuin/issues/1356))\n- Make `atuin account delete` void session + key ([#1393](https://github.com/atuinsh/atuin/issues/1393))\n- New clippy lints ([#1395](https://github.com/atuinsh/atuin/issues/1395))\n- Reenable enter_accept for bash ([#1408](https://github.com/atuinsh/atuin/issues/1408))\n- Respect ZSH's $ZDOTDIR environment variable ([#942](https://github.com/atuinsh/atuin/issues/942))\n\n\n### Documentation\n\n- Update sync.md ([#1409](https://github.com/atuinsh/atuin/issues/1409))\n- Update Arch Linux package URL in advanced-install.md ([#1407](https://github.com/atuinsh/atuin/issues/1407))\n- New stats config ([#1412](https://github.com/atuinsh/atuin/issues/1412))\n\n\n### Features\n\n- *(nix)* Add a nixpkgs overlay ([#1357](https://github.com/atuinsh/atuin/issues/1357))\n- Add metrics server and http metrics ([#1394](https://github.com/atuinsh/atuin/issues/1394))\n- Add some metrics related to Atuin as an app ([#1399](https://github.com/atuinsh/atuin/issues/1399))\n- Allow configuring stats prefix ([#1411](https://github.com/atuinsh/atuin/issues/1411))\n- Allow spaces in stats prefixes ([#1414](https://github.com/atuinsh/atuin/issues/1414))\n\n\n### Miscellaneous Tasks\n\n- *(readme)* Add contributor image to README ([#1430](https://github.com/atuinsh/atuin/issues/1430))\n- Update to sqlx 0.7.3 ([#1416](https://github.com/atuinsh/atuin/issues/1416))\n- `cargo update` ([#1419](https://github.com/atuinsh/atuin/issues/1419))\n- Update rusty_paseto and rusty_paserk ([#1420](https://github.com/atuinsh/atuin/issues/1420))\n- Run dependabot weekly, not daily ([#1423](https://github.com/atuinsh/atuin/issues/1423))\n- Don't group deps ([#1424](https://github.com/atuinsh/atuin/issues/1424))\n- Setup git cliff ([#1431](https://github.com/atuinsh/atuin/issues/1431))\n\n\n## 17.0.1\n\n### Bug Fixes\n\n- *(bash)* Improve output of `enter_accept` ([#1342](https://github.com/atuinsh/atuin/issues/1342))\n- *(enter_accept)* Clear old cmd snippet ([#1350](https://github.com/atuinsh/atuin/issues/1350))\n- *(fish)* Improve output for `enter_accept` ([#1341](https://github.com/atuinsh/atuin/issues/1341))\n\n\n## 17.0.0\n\n### Bug Fixes\n\n- *(1220)* Workspace Filtermode not handled in skim engine ([#1273](https://github.com/atuinsh/atuin/issues/1273))\n- *(nu)* Disable the up-arrow keybinding for Nushell ([#1329](https://github.com/atuinsh/atuin/issues/1329))\n- *(nushell)* Ignore stderr messages ([#1320](https://github.com/atuinsh/atuin/issues/1320))\n- *(ubuntu/arm*)* Detect non amd64 ubuntu and handle ([#1131](https://github.com/atuinsh/atuin/issues/1131))\n\n\n### Documentation\n\n- Update `workspace` config key to `workspaces` ([#1174](https://github.com/atuinsh/atuin/issues/1174))\n- Document the available format options of History list command ([#1234](https://github.com/atuinsh/atuin/issues/1234))\n\n\n### Features\n\n- *(installer)* Try installing via paru for the AUR ([#1262](https://github.com/atuinsh/atuin/issues/1262))\n- *(keyup)* Configure SearchMode for KeyUp invocation #1216 ([#1224](https://github.com/atuinsh/atuin/issues/1224))\n- Mouse selection support ([#1209](https://github.com/atuinsh/atuin/issues/1209))\n- Copy to clipboard ([#1249](https://github.com/atuinsh/atuin/issues/1249))\n\n\n### Refactor\n\n- Duplications reduced in order to align implementations of reading history files ([#1247](https://github.com/atuinsh/atuin/issues/1247))\n\n\n### Config.md\n\n- Invert mode detailed options ([#1225](https://github.com/atuinsh/atuin/issues/1225))\n\n\n## 16.0.0\n\n### Bug Fixes\n\n- *(docs)* List all presently documented commands ([#1140](https://github.com/atuinsh/atuin/issues/1140))\n- *(docs)* Correct command overview paths ([#1145](https://github.com/atuinsh/atuin/issues/1145))\n- *(server)* Teapot is a cup of coffee ([#1137](https://github.com/atuinsh/atuin/issues/1137))\n- Adjust broken link to supported shells ([#1013](https://github.com/atuinsh/atuin/issues/1013))\n- Fixes unix specific impl of shutdown_signal ([#1061](https://github.com/atuinsh/atuin/issues/1061))\n- Nushell empty hooks ([#1138](https://github.com/atuinsh/atuin/issues/1138))\n\n\n### Features\n\n- Do not allow empty passwords durring account creation ([#1029](https://github.com/atuinsh/atuin/issues/1029))\n\n\n### Skim\n\n- Fix filtering aggregates ([#1114](https://github.com/atuinsh/atuin/issues/1114))\n\n\n## 15.0.0\n\n### Documentation\n\n- Fix broken links in README.md ([#920](https://github.com/atuinsh/atuin/issues/920))\n- Fix \"From source\" `cd` command ([#937](https://github.com/atuinsh/atuin/issues/937))\n\n\n### Features\n\n- Add delete account option (attempt 2) ([#980](https://github.com/atuinsh/atuin/issues/980))\n\n\n### Miscellaneous Tasks\n\n- Uuhhhhhh crypto lol ([#805](https://github.com/atuinsh/atuin/issues/805))\n- Fix participle \"be ran\" -> \"be run\" ([#939](https://github.com/atuinsh/atuin/issues/939))\n\n\n### Cwd_filter\n\n- Much like history_filter, only it applies to cwd ([#904](https://github.com/atuinsh/atuin/issues/904))\n\n\n## 14.0.0\n\n### Bug Fixes\n\n- *(client)* Always read session_path from settings ([#757](https://github.com/atuinsh/atuin/issues/757))\n- *(installer)* Use case-insensitive comparison ([#776](https://github.com/atuinsh/atuin/issues/776))\n- Many wins were broken :memo: ([#789](https://github.com/atuinsh/atuin/issues/789))\n- Paste into terminal after switching modes ([#793](https://github.com/atuinsh/atuin/issues/793))\n- Record negative exit codes ([#821](https://github.com/atuinsh/atuin/issues/821))\n- Allow nix package to fetch dependencies from git ([#832](https://github.com/atuinsh/atuin/issues/832))\n\n\n### Documentation\n\n- *(README)* Fix activity graph link ([#753](https://github.com/atuinsh/atuin/issues/753))\n\n\n### Features\n\n- Add common default keybindings ([#719](https://github.com/atuinsh/atuin/issues/719))\n- Add an inline view mode ([#648](https://github.com/atuinsh/atuin/issues/648))\n- Add *Nushell* support ([#788](https://github.com/atuinsh/atuin/issues/788))\n- Add github action to test the nix builds ([#833](https://github.com/atuinsh/atuin/issues/833))\n\n\n### Miscellaneous Tasks\n\n- Remove tui vendoring ([#804](https://github.com/atuinsh/atuin/issues/804))\n- Use fork of skim ([#803](https://github.com/atuinsh/atuin/issues/803))\n\n\n### Nix\n\n- Add flake-compat ([#743](https://github.com/atuinsh/atuin/issues/743))\n\n\n## 13.0.0\n\n### Documentation\n\n- *(README)* Add static activity graph example ([#680](https://github.com/atuinsh/atuin/issues/680))\n- Remove human short flag from docs, duplicate of help -h ([#663](https://github.com/atuinsh/atuin/issues/663))\n- Fix typo in zh-CN/README.md ([#666](https://github.com/atuinsh/atuin/issues/666))\n\n\n### Features\n\n- *(history)* Add new flag to allow custom output format ([#662](https://github.com/atuinsh/atuin/issues/662))\n\n\n### Fish\n\n- Fix `atuin init` for the fish shell ([#699](https://github.com/atuinsh/atuin/issues/699))\n\n\n### Install.sh\n\n- Fallback to using cargo ([#639](https://github.com/atuinsh/atuin/issues/639))\n\n\n## 12.0.0\n\n### Documentation\n\n- Add more details about date parsing in the stats command ([#579](https://github.com/atuinsh/atuin/issues/579))\n\n\n## 0.10.0\n\n### Miscellaneous Tasks\n\n- Allow specifiying the limited of returned entries ([#364](https://github.com/atuinsh/atuin/issues/364))\n\n\n## 0.9.0\n\n### README\n\n- Add MacPorts installation instructions ([#302](https://github.com/atuinsh/atuin/issues/302))\n\n\n## 0.8.1\n\n### Bug Fixes\n\n- Get install.sh working on UbuntuWSL ([#260](https://github.com/atuinsh/atuin/issues/260))\n\n\n## 0.8.0\n\n### Bug Fixes\n\n- Resolve some issues with install.sh ([#188](https://github.com/atuinsh/atuin/issues/188))\n\n\n### Features\n\n- Login/register no longer blocking ([#216](https://github.com/atuinsh/atuin/issues/216))\n\n\n## 0.7.2\n\n### Bug Fixes\n\n- Dockerfile with correct glibc ([#198](https://github.com/atuinsh/atuin/issues/198))\n\n\n### Features\n\n- Allow input of credentials from stdin ([#185](https://github.com/atuinsh/atuin/issues/185))\n\n\n### Miscellaneous Tasks\n\n- Some new linting ([#201](https://github.com/atuinsh/atuin/issues/201))\n- Supply pre-build docker image ([#199](https://github.com/atuinsh/atuin/issues/199))\n- Add more eyre contexts ([#200](https://github.com/atuinsh/atuin/issues/200))\n- Improve build times ([#213](https://github.com/atuinsh/atuin/issues/213))\n\n\n## 0.7.1\n\n### Features\n\n- Build individual crates ([#109](https://github.com/atuinsh/atuin/issues/109))\n\n\n## 0.6.3\n\n### Bug Fixes\n\n- Help text\n\n\n### Features\n\n- Use directories project data dir\n\n\n### Miscellaneous Tasks\n\n- Use structopt wrapper instead of building clap by hand\n\n\n<!-- generated by git-cliff -->\n"
  },
  {
    "path": "CODE_OF_CONDUCT.md",
    "content": "# Contributor Covenant Code of Conduct\n\n## Our Pledge\n\nWe as members, contributors, and leaders pledge to make participation in our\ncommunity a harassment-free experience for everyone, regardless of age, body\nsize, visible or invisible disability, ethnicity, sex characteristics, gender\nidentity and expression, level of experience, education, socio-economic status,\nnationality, personal appearance, race, religion, or sexual identity\nand orientation.\n\nWe pledge to act and interact in ways that contribute to an open, welcoming,\ndiverse, inclusive, and healthy community.\n\n## Our Standards\n\nExamples of behavior that contributes to a positive environment for our\ncommunity include:\n\n* Demonstrating empathy and kindness toward other people\n* Being respectful of differing opinions, viewpoints, and experiences\n* Giving and gracefully accepting constructive feedback\n* Accepting responsibility and apologizing to those affected by our mistakes,\n  and learning from the experience\n* Focusing on what is best not just for us as individuals, but for the\n  overall community\n\nExamples of unacceptable behavior include:\n\n* The use of sexualized language or imagery, and sexual attention or\n  advances of any kind\n* Trolling, insulting or derogatory comments, and personal or political attacks\n* Public or private harassment\n* Publishing others' private information, such as a physical or email\n  address, without their explicit permission\n* Other conduct which could reasonably be considered inappropriate in a\n  professional setting\n\n## Enforcement Responsibilities\n\nCommunity leaders are responsible for clarifying and enforcing our standards of\nacceptable behavior and will take appropriate and fair corrective action in\nresponse to any behavior that they deem inappropriate, threatening, offensive,\nor harmful.\n\nCommunity leaders have the right and responsibility to remove, edit, or reject\ncomments, commits, code, wiki edits, issues, and other contributions that are\nnot aligned to this Code of Conduct, and will communicate reasons for moderation\ndecisions when appropriate.\n\n## Scope\n\nThis Code of Conduct applies within all community spaces, and also applies when\nan individual is officially representing the community in public spaces.\nExamples of representing our community include using an official e-mail address,\nposting via an official social media account, or acting as an appointed\nrepresentative at an online or offline event.\n\n## Enforcement\n\nInstances of abusive, harassing, or otherwise unacceptable behavior may be\nreported to the community leaders responsible for enforcement at\nellie@elliehuxtable.com.\nAll complaints will be reviewed and investigated promptly and fairly.\n\nAll community leaders are obligated to respect the privacy and security of the\nreporter of any incident.\n\n## Enforcement Guidelines\n\nCommunity leaders will follow these Community Impact Guidelines in determining\nthe consequences for any action they deem in violation of this Code of Conduct:\n\n### 1. Correction\n\n**Community Impact**: Use of inappropriate language or other behavior deemed\nunprofessional or unwelcome in the community.\n\n**Consequence**: A private, written warning from community leaders, providing\nclarity around the nature of the violation and an explanation of why the\nbehavior was inappropriate. A public apology may be requested.\n\n### 2. Warning\n\n**Community Impact**: A violation through a single incident or series\nof actions.\n\n**Consequence**: A warning with consequences for continued behavior. No\ninteraction with the people involved, including unsolicited interaction with\nthose enforcing the Code of Conduct, for a specified period of time. This\nincludes avoiding interactions in community spaces as well as external channels\nlike social media. Violating these terms may lead to a temporary or\npermanent ban.\n\n### 3. Temporary Ban\n\n**Community Impact**: A serious violation of community standards, including\nsustained inappropriate behavior.\n\n**Consequence**: A temporary ban from any sort of interaction or public\ncommunication with the community for a specified period of time. No public or\nprivate interaction with the people involved, including unsolicited interaction\nwith those enforcing the Code of Conduct, is allowed during this period.\nViolating these terms may lead to a permanent ban.\n\n### 4. Permanent Ban\n\n**Community Impact**: Demonstrating a pattern of violation of community\nstandards, including sustained inappropriate behavior,  harassment of an\nindividual, or aggression toward or disparagement of classes of individuals.\n\n**Consequence**: A permanent ban from any sort of public interaction within\nthe community.\n\n## Attribution\n\nThis Code of Conduct is adapted from the [Contributor Covenant][homepage],\nversion 2.0, available at\nhttps://www.contributor-covenant.org/version/2/0/code_of_conduct.html.\n\nCommunity Impact Guidelines were inspired by [Mozilla's code of conduct\nenforcement ladder](https://github.com/mozilla/diversity).\n\n[homepage]: https://www.contributor-covenant.org\n\nFor answers to common questions about this code of conduct, see the FAQ at\nhttps://www.contributor-covenant.org/faq. Translations are available at\nhttps://www.contributor-covenant.org/translations.\n"
  },
  {
    "path": "CONTRIBUTING.md",
    "content": "# Contributing\n\nThank you so much for considering contributing to Atuin! We really appreciate it <3\n\nDevelopment dependencies\n\n1. A rust toolchain ([rustup](https://rustup.rs) recommended)\n\nWe commit to supporting the latest stable version of Rust - nothing more, nothing less, no nightly.\n\nBefore working on anything, we suggest taking a copy of your Atuin data directory (`~/.local/share/atuin` on most \\*nix platforms). If anything goes wrong, you can always restore it!\n\nWhile data directory backups are always a good idea, you can instruct Atuin to use custom path using the following environment variables:\n\n```shell\nexport ATUIN_RECORD_STORE_PATH=/tmp/atuin_records.db\nexport ATUIN_DB_PATH=/tmp/atuin_dev.db\nexport ATUIN_KV__DB_PATH=/tmp/atuin_kv.db\nexport ATUIN_SCRIPTS__DB_PATH=/tmp/atuin_scripts.db\n```\n\nIt is also recommended to update your `$PATH` so that the pre-exec scripts would use the locally built version:\n\n```shell\nexport PATH=\"./target/release:$PATH\"\n```\n\nIf you'd like to load a different configuration file, set `ATUIN_CONFIG_DIR` to a folder that contains your `config.toml` file:\n\n```shell\nexport ATUIN_CONFIG_DIR=/tmp/atuin-config/\n```\n\nThese variable exports can be added in a local `.envrc` file, read by [direnv](https://direnv.net/).\n\n## PRs\n\nIt can speed up the review cycle if you consent to maintainers pushing to your branch. This will only be in the case of small fixes or adjustments, and not anything large. If you feel OK with this, please check the box on the template!\n\n## What to work on?\n\nAny issues labeled \"bug\" or \"help wanted\" would be fantastic, just drop a comment and feel free to ask for help!\n\nIf there's anything you want to work on that isn't already an issue, either open a feature request or get in touch on the [forum](https://forum.atuin.sh)/Discord.\n\n## Setup\n\n```\ngit clone https://github.com/atuinsh/atuin\ncd atuin\ncargo build\n```\n\n## Running\n\nWhen iterating on a feature, it's useful to use `cargo run`\n\nFor example, if working on a search feature\n\n```\ncargo run -- search --a-new-flag\n```\n\nWhile iterating on the server, I find it helpful to run a new user on my system, with `sync_server` set to be `localhost`.\n\n## Tests\n\nOur test coverage is currently not the best, but we are working on it! Generally tests live in the file next to the functionality they are testing, and are executed just with `cargo test`.\n\n## Logging and Debugging\n\n### Log Files\n\nAtuin writes logs to `~/.atuin/logs` unless configured otherwise. Log files are rotated daily and retained for 4 days by default:\n\n- `search.log.*` - Interactive search session logs\n- `daemon.log.*` - Background daemon logs\n\n### Log Levels\n\nYou can set the `ATUIN_LOG` environment variable to override log verbosity from the config file:\n\n```shell\nATUIN_LOG=debug atuin search  # Enable debug logging\nATUIN_LOG=trace atuin search  # Enable trace logging (very verbose)\n```\n\n### Span Timing (Performance Profiling)\n\nFor performance analysis, you can capture detailed span timing data as JSON:\n\n```shell\nATUIN_SPAN=spans.json atuin search\n```\n\nThis creates a JSON file with timing information for each instrumented span, including:\n- `time.busy` - Time actively executing code\n- `time.idle` - Time awaiting async operations (I/O, child tasks)\n\nThe `scripts/span-table.ts` script analyzes these logs:\n\n```shell\n# Summary view - shows all spans with timing stats\nbun scripts/span-table.ts spans.json\n\n# Detail view - shows individual calls for a specific span\nbun scripts/span-table.ts spans.json --detail daemon_search\n\n# Filter to specific spans\nbun scripts/span-table.ts spans.json --filter \"search|hydrate\"\n```\n\nThis is useful for comparing performance between different search implementations or identifying bottlenecks.\n\n## Migrations\n\nBe careful creating database migrations - once your database has migrated ahead of current stable, there is no going back\n\n### Stickers\n\nWe try to ship anyone contributing to Atuin a sticker! Only contributors get a shiny one. Fill out [this form](https://noteforms.com/forms/contributors-stickers) if you'd like one.\n"
  },
  {
    "path": "CONTRIBUTORS",
    "content": "0x4A6F <0x4A6F@users.noreply.github.com>\nAleks Bunin <sashkab@users.noreply.github.com>\nAlex Hamilton <1622250+Aehmlo@users.noreply.github.com>\nAlexandre GV. <contact@alexandregv.fr>\nAloxaf <bailong104@gmail.com>\nAlpha Chen <alpha@kejadlen.dev>\nAmos Bird <amosbird@gmail.com>\nAnderson <141751473+digital-cuttlefish@users.noreply.github.com>\nAndrew Aylett <andrew@aylett.co.uk>\nAndrew Lee <32912555+candrewlee14@users.noreply.github.com>\nAnish Pallati <anishp@duck.com>\nAustin Schey <aschey13@gmail.com>\navinassh <640792+avinassh@users.noreply.github.com>\nAzzam S.A <17734314+azzamsa@users.noreply.github.com>\nb3nj5m1n <47924309+b3nj5m1n@users.noreply.github.com>\nBaptiste <32563450+BapRx@users.noreply.github.com>\nBen J <bdavjones@gmail.com>\nBenjamin Vergnaud <9599845+bvergnaud@users.noreply.github.com>\nBenjamin Weinstein-Raun <b@w-r.me>\nBlair Noctis <4474501+nc7s@users.noreply.github.com>\nBrad Robel-Forrest <brad@bitpony.com>\nBraelyn Boynton <bboynton97@gmail.com>\nBrian Kung <2836167+briankung@users.noreply.github.com>\nBruce Huang <helbingxxx@gmail.com>\nc-14 <git@c-14.de>\nCaleb Maclennan <caleb@alerque.com>\nCh. (Chanwhi Choi) <ccwpc@hanmail.net>\nChandra Kiran G <chandra.kiran@cai-solutions.com>\nchitao1234 <1139954766@qq.com>\nChris Rose <offline@offby1.net>\nConrad Ludgate <conradludgate@gmail.com>\nCosmicHorror <LovecraftianHorror@pm.me>\nCristian Le <git@lecris.dev>\nCristian Le <github@lecris.me>\nCULT PONY <67918945+cultpony@users.noreply.github.com>\ncyqsimon <28627918+cyqsimon@users.noreply.github.com>\nDagan McGregor <d.mcgregor@gns.cri.nz>\nDaniel <daniel.hub@outlook.de>\nDaniel Carosone <daniel.carosone@gmail.com>\nDaniPopes <57450786+DaniPopes@users.noreply.github.com>\nDavid <drmorr@appliedcomputing.io>\nDavid <drmorr@evokewonder.com>\nDavid Chocholatý <chocholaty.david@protonmail.com>\nDavid Jack Wange Olrik <david@olrik.dk>\nDavid Legrand <1110600+davlgd@users.noreply.github.com>\nDennis Trautwein <git@dtrautwein.eu>\ndependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>\nDevin Buhl <onedr0p@users.noreply.github.com>\nDhruv Thakur <13575379+dhth@users.noreply.github.com>\nDiego Carrasco Gubernatis <557703+dacog@users.noreply.github.com>\nDieter Eickstaedt <eickstaedt@deicon.de>\nDom Rodriguez <shymega@users.noreply.github.com>\nDongxu Wang <dongxu@apache.org>\nDS/Charlie <82801887+ds-cbo@users.noreply.github.com>\nEd Ive <ed.ivve@gmail.com>\nEdward Loveall <edward@edwardloveall.com>\nEllie Huxtable <ellie@atuin.sh>\nEmanuele Panzeri <thepanz@gmail.com>\nEric Crosson <EricCrosson@users.noreply.github.com>\nEric Hodel <drbrain@segment7.net>\nEric Long <i@hack3r.moe>\nEric Ripa <eric@ripa.io>\nErwin Kroon <123574+ekroon@users.noreply.github.com>\neth3lbert <eth3lbert+dev@gmail.com>\nEthan Brierley <ethanboxx@gmail.com>\nEvan McBeth <64177332+AtomicRobotMan0101@users.noreply.github.com>\nEvan Purkhiser <evanpurkhiser@gmail.com>\nFarid Zakaria <farid.m.zakaria@gmail.com>\nFelix Yan <felixonmars@archlinux.org>\nFrank Hamand <frankhamand@gmail.com>\nfrukto <fruktopus@gmail.com>\nGokul <appu.yess@gmail.com>\nHamza Hamud <53880692+hhamud@users.noreply.github.com>\nHelmut K. C. Tessarek <tessarek@evermeet.cx>\nHerby Gillot <herby.gillot@gmail.com>\nHesam Pakdaman <14890379+hesampakdaman@users.noreply.github.com>\nHilmar Wiegand <me@hwgnd.de>\nHunter Casten <41604962+enchantednatures@users.noreply.github.com>\nIan Manske <ian.manske@pm.me>\nIan Smith <iansmith@honeycomb.io>\nIan Smith <ismith@mit.edu>\nIlkin Bayramli <43158991+ibayramli@users.noreply.github.com>\nIvan Toriya <43750521+ivan-toriya@users.noreply.github.com>\nJ. Emiliano Deustua <edeustua@gmail.com>\nJakob Schrettenbrunner <dev@schrej.net>\nJakub Jirutka <jakub@jirutka.cz>\nJakub Panek <me@panekj.dev>\nJames Trew <66286082+jamestrew@users.noreply.github.com>\nJamie Quigley <jamie@quigley.xyz>\nJan Larres <jan@majutsushi.net>\nJannik <32144358+mozzieongit@users.noreply.github.com>\nJannik <jannik.peters@posteo.de>\nJax Young <jaxvanyang@gmail.com>\njean-santos <ewqjean@gmail.com>\njean-santos <jeanpnsantos@gmail.com>\nJeff Gould <JRGould@gmail.com>\nJeremy Cline <github@declined.dev>\nJeremy Cline <jeremy@jcline.org>\nJerome Ducret <jdiphone34@gmail.com>\njfmontanaro <jfmonty2@gmail.com>\nJinn Koriech <jinnko@users.noreply.github.com>\nJinna Kiisuo <jinna@nocturnal.fi>\nJoe Ardent <nebkor@users.noreply.github.com>\nJohannes Baiter <johannes.baiter@gmail.com>\nJosef Friedrich <josef@friedrich.rocks>\nJT <547158+jntrnr@users.noreply.github.com>\nJulien P <julien@caffeine.lu>\nJustin Su <injustsu@gmail.com>\nJános Illés <ijanos@gmail.com>\nKian-Meng Ang <kianmeng.ang@gmail.com>\nKjetil Jørgensen <kjetijor+github@gmail.com>\nKlas Mellbourn <klas@mellbourn.net>\nKoichi Murase <myoga.murase@gmail.com>\nKorvin Szanto <Korvinszanto@gmail.com>\nKrithic Kumar <30691152+notjedi@users.noreply.github.com>\nKrut Patel <kroot.patel@gmail.com>\nLaurent le Beau-Martin <1180863+laurentlbm@users.noreply.github.com>\nlchausmann <jazz-github@zqz.dk>\nLeoniePhiline <22329650+LeoniePhiline@users.noreply.github.com>\nLuca Comellini <luca.com@gmail.com>\nLucas Burns <44355502+lmburns@users.noreply.github.com>\nLucas Trzesniewski <lucas.trzesniewski@gmail.com>\nLucy <lucy@absolucy.moe>\nLuke Baker <lukebaker@gmail.com>\nLuke Karrys <luke@lukekarrys.com>\nMag Mell <sakiiily@aosc.io>\nManel Vilar <manelvf@gmail.com>\nMarcin Puc <tranzystorek.io@protonmail.com>\nMarijan Smetko <msmetko@msmetko.xyz>\nMark Wotton <mwotton@gmail.com>\nMartin Indra <martin.indra@mgn.cz>\nMartin Junghanns <m.junghanns@mailbox.org>\nMat Jones <mat@mjones.network>\nMatheus Martins <matheuscumth@gmail.com>\nMatt Godbolt <matt@godbolt.org>\nMatthew Berryman <matthew@acrossthecloud.net>\nMatthias Beyer <mail@beyermatthias.de>\nMatthieu LAURENT <matthieu.laurent69@protonmail.com>\nMattias Eriksson <mattias.eriksson@tutanota.com>\nMaurice Escher <maurice.escher@sap.com>\nMaxim Burgerhout <maxim@wzzrd.com>\nMaxim Uvarov <maxim-uvarov@users.noreply.github.com>\nmb6ockatf <104227451+mb6ockatf@users.noreply.github.com>\nmentalisttraceur <mentalisttraceur@gmail.com>\nMichael Bianco <iloveitaly@gmail.com>\nMichael Mior <michael.mior@gmail.com>\nMichael Vincent <377567+Vynce@users.noreply.github.com>\nMichele Azzolari <michele@azzolari.it>\nMichelle Tilley <michelle@michelletilley.net>\nMike Pastore <mwpastore@users.noreply.github.com>\nMike Tsao <mike@sowbug.com>\nmmx <github@m2nx.com>\nmorguldir <morguldir@protonmail.com>\nmundry <1453314+mundry@users.noreply.github.com>\nNelyah <Nelyah@users.noreply.github.com>\nNemo157 <git@nemo157.com>\nnetworkException <git@nwex.de>\nNico Kokonas <nico@nicomee.com>\nNiklas Hambüchen <mail@nh2.me>\nnoyez <noyez@ithryn.net>\nOmer Katz <omer.drow@gmail.com>\nonkelT2 <126604057+onkelT2@users.noreply.github.com>\nOnè <43485962+c-git@users.noreply.github.com>\nOrhun Parmaksız <orhunparmaksiz@gmail.com>\nP T Weir <phil.weir@flaxandteal.co.uk>\nPatrick <pmarschik@users.noreply.github.com>\nPatrick Decat <pdecat@gmail.com>\nPatrick Jackson <patrick@jackson.dev>\nPavel Ivanov <mr.pavel.ivanov@gmail.com>\nPer Modin <pmodin@users.noreply.github.com>\nPeter Brunner <peter@lugoues.net>\nPeter Holloway <holloway.p.r@gmail.com>\nPhilippe Normand <phil@base-art.net>\nPhilippe Normand <philn@igalia.com>\nPierluigi <82404704+IoSonoPiero@users.noreply.github.com>\nPlamen Dimitrov <pdimitrov@pevogam.com>\nPoliorcetics <poliorcetics@users.noreply.github.com>\npostmath <postmath@users.noreply.github.com>\nprintfn <1643883+printfn@users.noreply.github.com>\nQiming Xu <33349132+xqm32@users.noreply.github.com>\nRain <rain@sunshowers.io>\nRamses <ramses@well-founded.dev>\nRemmy Cat Stock <3317423+remmycat@users.noreply.github.com>\nRemo Senekowitsch <remo@buenzli.dev>\nReverier Xu <reverier.xu@outlook.com>\nRichard de Boer <git@tubul.net>\nRichard Jones <4550158+RichardDRJ@users.noreply.github.com>\nRichard Turner <63139+zygous@users.noreply.github.com>\nRobin Millette <robin@millette.info>\nrriski <github@timoriski.fi>\nSam Edwards <sam@samedwards.ca>\nSam Lanning <sam@samlanning.com>\nSamson <samson_gh@onepatchdown.net>\nSandro <sandro.jaeckel@gmail.com>\nSatyarth Sampath <satyarth.23@gmail.com>\nsdr135284 <54752759+sdr135284@users.noreply.github.com>\nShroomy <sporeventexplosion@gmail.com>\nSimon <simon_bull@mckinsey.com>\nSimon Elsbrock <simon@iodev.org>\nslamp <slaamp@gmail.com>\nSteve Kemp <steve@steve.org.uk>\nSteven Xu <stevenxxiu@users.noreply.github.com>\nSven-Hendrik Haase <svenstaro@gmail.com>\nThomas Buckley-Houston <tom@tombh.co.uk>\nTobias Genannt <tobias.genannt@gmail.com>\nTobias Genannt <tobias.genannt@qbeyond.de>\nTobias Hunger <tobias.hunger@gmail.com>\nTom Cammann <cammann.tom@gmail.com>\nTom Cammann <tom.cammann@oracle.com>\nTrygve Aaberge <trygveaa@gmail.com>\nTymanWasTaken <tbeckman530@gmail.com>\nUbiquitous Photon <39134173+UbiquitousPhoton@users.noreply.github.com>\nViolet Shreve <github@shreve.io>\nVlad Stepanov <8uk.8ak@gmail.com>\nVladislav Stepanov <8uk.8ak@gmail.com>\nVuiMuich <jm.spam@gmx.net>\nWebmaster At Cosmic DNA <92752640+DanielAtCosmicDNA@users.noreply.github.com>\nWill Fancher <elvishjerricco@gmail.com>\nWind <WindSoilder@outlook.com>\nWindSoilder <WindSoilder@outlook.com>\nwinston <hey@winston.sh>\nwpbrz <61665187+wpbrz@users.noreply.github.com>\nXavier Vello <xavier.vello@gmail.com>\nxfzv <78810647+xfzv@users.noreply.github.com>\nYannick Ulrich <yannick.ulrich@durham.ac.uk>\nYaroslav Halchenko <debian@onerussian.com>\nYolo <noah.chang@outlook.com>\nYonatan Goldschmidt <yon.goldschmidt@gmail.com>\nYummyOreo <bobgim20@gmail.com>\nYuvi Panda <yuvipanda@gmail.com>\nZhanibek Adilbekov <zhanibek.adilbekov@proton.me>\nZhiHong Li <joker_lizhih@163.com>\nZhizhen He <hezhizhen.yi@gmail.com>\néclairevoyant <848000+eclairevoyant@users.noreply.github.com>\n依云 <lilydjwg@gmail.com>\n镜面王子 <153555712@qq.com>\n"
  },
  {
    "path": "Cargo.toml",
    "content": "[workspace]\nmembers = [\"crates/*\", \"crates/atuin-nucleo/matcher\", \"crates/atuin-nucleo/bench\"]\n\nresolver = \"2\"\nexclude = [\"ui/backend\", \"crates/atuin-nucleo/matcher/fuzz\"]\n\n[workspace.package]\nversion = \"18.13.3\"\nauthors = [\"Ellie Huxtable <ellie@atuin.sh>\"]\nrust-version = \"1.94.0\"\nlicense = \"MIT\"\nhomepage = \"https://atuin.sh\"\nrepository = \"https://github.com/atuinsh/atuin\"\nreadme = \"README.md\"\n\n[workspace.dependencies]\nasync-trait = \"0.1.58\"\natuin-client = { path = \"crates/atuin-client\", version = \"18.13.3\" }\natuin-common = { path = \"crates/atuin-common\", version = \"18.13.3\" }\natuin-daemon = { path = \"crates/atuin-daemon\", version = \"18.13.3\" }\natuin-dotfiles = { path = \"crates/atuin-dotfiles\", version = \"18.13.3\" }\natuin-history = { path = \"crates/atuin-history\", version = \"18.13.3\" }\natuin-kv = { path = \"crates/atuin-kv\", version = \"18.13.3\" }\natuin-scripts = { path = \"crates/atuin-scripts\", version = \"18.13.3\" }\natuin-server = { path = \"crates/atuin-server\", version = \"18.13.3\" }\natuin-server-database = { path = \"crates/atuin-server-database\", version = \"18.13.3\" }\natuin-server-postgres = { path = \"crates/atuin-server-postgres\", version = \"18.13.3\" }\natuin-server-sqlite = { path = \"crates/atuin-server-sqlite\", version = \"18.13.3\" }\natuin-nucleo = { path = \"crates/atuin-nucleo\", version = \"0.6.0\" }\natuin-nucleo-matcher = { path = \"crates/atuin-nucleo/matcher\", version = \"0.3.1\" }\nbase64 = \"0.22\"\ncrossterm = \"0.29.0\"\nlog = \"0.4\"\ntime = { version = \"0.3.47\", features = [\n  \"serde-human-readable\",\n  \"macros\",\n  \"local-offset\",\n] }\nclap = { version = \"4.5.7\", features = [\"derive\"] }\nconfig = { version = \"0.15.8\", default-features = false, features = [\"toml\"] }\ndirectories = \"6.0.0\"\neyre = \"0.6\"\nfs-err = \"3.1\"\ninterim = { version = \"0.2.0\", features = [\"time_0_3\"] }\nitertools = \"0.14.0\"\nrand = { version = \"0.8.5\", features = [\"std\"] }\nsemver = \"1.0.20\"\nserde = { version = \"1.0.202\", features = [\"derive\"] }\nserde_json = \"1.0.119\"\ntokio = { version = \"1\", features = [\"full\"] }\nuuid = { version = \"1.9\", features = [\"v4\", \"v7\", \"serde\"] }\nwhoami = \"2.1.0\"\ntyped-builder = \"0.18.2\"\npretty_assertions = \"1.3.0\"\nthiserror = \"2\"\nrustix = { version = \"1.1.4\", features = [\"process\", \"fs\"] }\ntower = \"0.5\"\ntracing = \"0.1\"\nratatui = \"0.30.0\"\nsql-builder = \"3\"\ntempfile = { version = \"3.19\" }\nminijinja = \"2.9.0\"\nrustls = { version = \"0.23\", default-features = false, features = [\n  \"ring\",\n  \"std\",\n  \"tls12\",\n] }\n\n[workspace.dependencies.tracing-subscriber]\nversion = \"0.3\"\nfeatures = [\"ansi\", \"fmt\", \"registry\", \"env-filter\", \"json\"]\n\n[workspace.dependencies.reqwest]\nversion = \"0.13\"\nfeatures = [\"json\", \"rustls-no-provider\", \"stream\"]\ndefault-features = false\n\n[workspace.dependencies.sqlx]\nversion = \"0.8\"\nfeatures = [\"runtime-tokio-rustls\", \"time\", \"postgres\", \"uuid\"]\n\n# The profile that 'cargo dist' will build with\n[profile.dist]\ninherits = \"release\"\nlto = \"thin\"\nstrip = \"symbols\"\n"
  },
  {
    "path": "Dockerfile",
    "content": "FROM lukemathwalker/cargo-chef:latest-rust-1.94.0-slim-bookworm AS chef\nWORKDIR app\n\nFROM chef AS planner\nCOPY . .\nRUN cargo chef prepare --recipe-path recipe.json\n\nFROM chef AS builder\n\n# Ensure working C compile setup (not installed by default in arm64 images)\nRUN apt update && apt install build-essential -y\n\nCOPY --from=planner /app/recipe.json recipe.json\nRUN cargo chef cook --release --recipe-path recipe.json\n\nCOPY . .\nRUN cargo build --release --bin atuin-server\n\nFROM debian:bookworm-20260202-slim AS runtime\n\nRUN useradd -c 'atuin user' atuin && mkdir /config && chown atuin:atuin /config\n# Install ca-certificates for webhooks to work\nRUN apt update && apt install ca-certificates -y && rm -rf /var/lib/apt/lists/*\nWORKDIR app\n\nUSER atuin\n\nENV TZ=Etc/UTC\nENV RUST_LOG=atuin_server=info\nENV ATUIN_CONFIG_DIR=/config\n\nCOPY --from=builder /app/target/release/atuin-server /usr/local/bin\nENTRYPOINT [\"/usr/local/bin/atuin-server\"]\n"
  },
  {
    "path": "LICENSE",
    "content": "MIT License\n\nCopyright (c) 2021 Ellie Huxtable\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
  },
  {
    "path": "README.md",
    "content": "<p align=\"center\">\n <picture>\n  <source media=\"(prefers-color-scheme: dark)\" srcset=\"https://github.com/atuinsh/atuin/assets/53315310/13216a1d-1ac0-4c99-b0eb-d88290fe0efd\">\n  <img alt=\"Text changing depending on mode. Light: 'So light!' Dark: 'So dark!'\" src=\"https://github.com/atuinsh/atuin/assets/53315310/08bc86d4-a781-4aaa-8d7e-478ae6bcd129\">\n</picture>\n</p>\n\n<p align=\"center\">\n<em>magical shell history</em>\n</p>\n\n<hr/>\n\n<p align=\"center\">\n  <a href=\"https://github.com/atuinsh/atuin/actions?query=workflow%3ARust\"><img src=\"https://img.shields.io/github/actions/workflow/status/atuinsh/atuin/rust.yml?style=flat-square\" /></a>\n  <a href=\"https://crates.io/crates/atuin\"><img src=\"https://img.shields.io/crates/v/atuin.svg?style=flat-square\" /></a>\n  <a href=\"https://crates.io/crates/atuin\"><img src=\"https://img.shields.io/crates/d/atuin.svg?style=flat-square\" /></a>\n  <a href=\"https://github.com/atuinsh/atuin/blob/main/LICENSE\"><img src=\"https://img.shields.io/crates/l/atuin.svg?style=flat-square\" /></a>\n  <a href=\"https://discord.gg/Fq8bJSKPHh\"><img src=\"https://img.shields.io/discord/954121165239115808\" /></a>\n  <a rel=\"me\" href=\"https://hachyderm.io/@atuin\"><img src=\"https://img.shields.io/mastodon/follow/109944632283122560?domain=https%3A%2F%2Fhachyderm.io&style=social\"/></a>\n  <a href=\"https://twitter.com/atuinsh\"><img src=\"https://img.shields.io/twitter/follow/atuinsh?style=social\" /></a>\n</p>\n\n[English] | [简体中文]\n\nAtuin replaces your existing shell history with a SQLite database, and records\nadditional context for your commands. Additionally, it provides optional and\n_fully encrypted_ synchronisation of your history between machines, via an Atuin\nserver.\n\n<p align=\"center\">\n  <img src=\"demo.gif\" alt=\"animated\" width=\"80%\" />\n</p>\n\n<p align=\"center\">\n<em>exit code, duration, time and command shown</em>\n</p>\n\nAs well as the search UI, it can do things like this:\n\n```\n# search for all successful `make` commands, recorded after 3pm yesterday\natuin search --exit 0 --after \"yesterday 3pm\" make\n```\n\nYou may use either the server I host, or host your own! Or just don't use sync\nat all. As all history sync is encrypted, I couldn't access your data even if\nI wanted to. And I **really** don't want to.\n\n## Features\n\n- rebind `ctrl-r` and `up` (configurable) to a full screen history search UI\n- store shell history in a sqlite database\n- back up and sync **encrypted** shell history\n- the same history across terminals, across sessions, and across machines\n- log exit code, cwd, hostname, session, command duration, etc\n- calculate statistics such as \"most used command\"\n- old history file is not replaced\n- quick-jump to previous items with <kbd>Alt-\\<num\\></kbd>\n- switch filter modes via ctrl-r; search history just from the current session, directory, or globally\n- enter to execute a command, tab to edit\n\n## Documentation\n\n- [Quickstart](#quickstart)\n- [Install](https://docs.atuin.sh/guide/installation/)\n- [Setting up sync](https://docs.atuin.sh/guide/sync/)\n- [Import history](https://docs.atuin.sh/guide/import/)\n- [Basic usage](https://docs.atuin.sh/guide/basic-usage/)\n\n## Supported Shells\n\n- zsh\n- bash\n- fish\n- nushell\n- xonsh\n- powershell (tier 2 support)\n\n## Community\n\n### Forum\n\nAtuin has a community forum, please ask here for help and support: <https://forum.atuin.sh/>\n\n### IRC\n\nWe're also available via #atuin on libera.chat\n\n### Discord\n\nAtuin also has a community Discord, available [here](https://discord.gg/jR3tfchVvW)\n\n# Quickstart\n\nThis will sign you up for the Atuin Cloud sync server. Everything is end-to-end encrypted, so your secrets are safe!\n\nRead more in the [docs](https://docs.atuin.sh) for an offline setup, self hosted server, and more.\n\n```\ncurl --proto '=https' --tlsv1.2 -LsSf https://setup.atuin.sh | sh\n\natuin register -u <USERNAME> -e <EMAIL>\natuin import auto\natuin sync\n```\n\nThen restart your shell!\n\n> [!NOTE]\n>\n> **For Bash users**: The above sets up `bash-preexec` for necessary hooks, but\n> `bash-preexec` has limitations. For details, please see the\n> [Bash](https://docs.atuin.sh/guide/installation/#installing-the-shell-plugin)\n> section of the shell plugin documentation.\n\n# Security\n\nIf you find any security issues, we'd appreciate it if you could alert <ellie@atuin.sh>\n\n# Contributors\n\n<a href=\"https://github.com/atuinsh/atuin/graphs/contributors\">\n  <img src=\"https://contrib.rocks/image?repo=atuinsh/atuin&max=300\" />\n</a>\n\nMade with [contrib.rocks](https://contrib.rocks).\n\n[English]: ./README.md\n[简体中文]: ./docs-i18n/zh-CN/README.md\n"
  },
  {
    "path": "atuin.nix",
    "content": "# Atuin package definition\n#\n# This file will be similar to the package definition in nixpkgs:\n#     https://github.com/NixOS/nixpkgs/blob/master/pkgs/by-name/at/atuin/package.nix\n#\n# Helpful documentation: https://github.com/NixOS/nixpkgs/blob/master/doc/languages-frameworks/rust.section.md\n{\n  lib,\n  stdenv,\n  installShellFiles,\n  rustPlatform,\n  libiconv,\n}:\nrustPlatform.buildRustPackage {\n  name = \"atuin\";\n\n  src = lib.cleanSource ./.;\n\n  cargoLock = {\n    lockFile = ./Cargo.lock;\n    # Allow dependencies to be fetched from git and avoid having to set the outputHashes manually\n    allowBuiltinFetchGit = true;\n  };\n\n  nativeBuildInputs = [installShellFiles];\n\n  buildInputs = lib.optionals stdenv.isDarwin [libiconv];\n\n  postInstall = ''\n    installShellCompletion --cmd atuin \\\n      --bash <($out/bin/atuin gen-completions -s bash) \\\n      --fish <($out/bin/atuin gen-completions -s fish) \\\n      --zsh <($out/bin/atuin gen-completions -s zsh)\n  '';\n\n  doCheck = false;\n\n  meta = with lib; {\n    description = \"Replacement for a shell history which records additional commands context with optional encrypted synchronization between machines\";\n    homepage = \"https://github.com/atuinsh/atuin\";\n    license = licenses.mit;\n    mainProgram = \"atuin\";\n  };\n}\n"
  },
  {
    "path": "atuin.plugin.zsh",
    "content": "# shellcheck disable=2148,SC2168,SC1090,SC2125\nlocal FOUND_ATUIN=$+commands[atuin]\n\nif [[ $FOUND_ATUIN -eq 1 ]]; then\n  source <(atuin init zsh)\nfi\n"
  },
  {
    "path": "cliff.toml",
    "content": "# git-cliff ~ default configuration file\n# https://git-cliff.org/docs/configuration\n#\n# Lines starting with \"#\" are comments.\n# Configuration options are organized into tables and keys.\n# See documentation for more information on available options.\n\n[changelog]\n# changelog header\nheader = \"\"\"\n# Changelog\\n\nAll notable changes to this project will be documented in this file.\\n\n\"\"\"\n# template for the changelog body\n# https://keats.github.io/tera/docs/#introduction\nbody = \"\"\"\n{% if version %}\\\n    ## {{ version | trim_start_matches(pat=\"v\") }}\n{% else %}\\\n    ## [unreleased]\n{% endif %}\\\n{% for group, commits in commits | group_by(attribute=\"group\") %}\n    ### {{ group | upper_first }}\n    {% for commit in commits\n    | filter(attribute=\"scope\")\n    | sort(attribute=\"scope\") %}\n        - *({{commit.scope}})* {{ commit.message | upper_first }}\n        {%- if commit.breaking %}\n        {% raw %}  {% endraw %}- **BREAKING**: {{commit.breaking_description}}\n        {%- endif -%}\n    {%- endfor -%}\n    {% raw %}\\n{% endraw %}\\\n    {%- for commit in commits %}\n        {%- if commit.scope -%}\n        {% else -%}\n            - {{ commit.message | upper_first }}\n            {% if commit.breaking -%}\n            {% raw %}  {% endraw %}- **BREAKING**: {{commit.breaking_description}}\n            {% endif -%}\n        {% endif -%}\n    {% endfor -%}\n    {% raw %}\\n{% endraw %}\\\n{% endfor %}\\n\n\"\"\"\n\n# remove the leading and trailing whitespace from the template\ntrim = true\n# changelog footer\nfooter = \"\"\"\n<!-- generated by git-cliff -->\n\"\"\"\n# postprocessors\npostprocessors = [\n  { pattern = '<REPO>', replace = \"https://github.com/atuinsh/atuin\" }, # replace repository URL\n]\n[git]\n# parse the commits based on https://www.conventionalcommits.org\nconventional_commits = true\n# filter out the commits that are not conventional\nfilter_unconventional = true\n# process each line of a commit as an individual commit\nsplit_commits = false\n# regex for preprocessing the commit messages\ncommit_preprocessors = [\n  { pattern = '\\((\\w+\\s)?#([0-9]+)\\)', replace = \"([#${2}](<REPO>/issues/${2}))\" }, # replace issue numbers\n]\n# regex for parsing and grouping commits\ncommit_parsers = [\n  { message = \"^feat\", group = \"Features\" },\n  { message = \"^fix\", group = \"Bug Fixes\" },\n  { message = \"^doc\", group = \"Documentation\" },\n  { message = \"^perf\", group = \"Performance\" },\n  { message = \"^refactor\", group = \"Refactor\" },\n  { message = \"^style\", group = \"Styling\" },\n  { message = \"^test\", group = \"Testing\" },\n  { message = \"^chore\\\\(release\\\\): prepare for\", skip = true },\n  { message = \"^chore\\\\(deps\\\\)\", skip = true },\n  { message = \"^chore\\\\(pr\\\\)\", skip = true },\n  { message = \"^chore\\\\(pull\\\\)\", skip = true },\n  { message = \"^chore|ci\", group = \"Miscellaneous Tasks\" },\n  { body = \".*security\", group = \"Security\" },\n  { message = \"^revert\", group = \"Revert\" },\n]\n# protect breaking changes from being skipped due to matching a skipping commit_parser\nprotect_breaking_commits = false\n# filter out the commits that are not matched by commit parsers\nfilter_commits = false\n# regex for matching git tags\ntag_pattern = \"v[0-9].*\"\n\n# regex for skipping tags\nskip_tags = \"v0.1.0-beta.1\"\n# regex for ignoring tags\nignore_tags = \"prerelease|beta|alpha\"\n# sort the tags topologically\ntopo_order = false\n# sort the commits inside sections by oldest/newest order\nsort_commits = \"oldest\"\n# limit the number of commits included in the changelog.\n# limit_commits = 42\n"
  },
  {
    "path": "crates/atuin/Cargo.toml",
    "content": "[package]\nname = \"atuin\"\nedition = \"2024\"\ndescription = \"atuin - magical shell history\"\nreadme = \"./README.md\"\n\nrust-version = { workspace = true }\nversion = { workspace = true }\nauthors = { workspace = true }\nlicense = { workspace = true }\nhomepage = { workspace = true }\nrepository = { workspace = true }\n\n[package.metadata.binstall]\npkg-url = \"{ repo }/releases/download/v{ version }/{ name }-{ target }.tar.gz\"\nbin-dir = \"{ name }-{ target }/{ bin }{ binary-ext }\"\npkg-fmt = \"tgz\"\n\n[package.metadata.deb]\nmaintainer = \"Ellie Huxtable <ellie@elliehuxtable.com>\"\ncopyright = \"2021, Ellie Huxtable <ellie@elliehuxtable.com>\"\nlicense-file = [\"LICENSE\"]\ndepends = \"$auto\"\nsection = \"utility\"\n\n[package.metadata.rpm]\npackage = \"atuin\"\n\n[package.metadata.rpm.cargo]\nbuildflags = [\"--release\"]\n\n[package.metadata.rpm.targets]\natuin = { path = \"/usr/bin/atuin\" }\n\n[features]\ndefault = [\"client\", \"sync\", \"clipboard\", \"check-update\", \"daemon\", \"ai\", \"hex\"]\nclient = [\"atuin-client\"]\nsync = [\"atuin-client/sync\"]\ndaemon = [\"atuin-client/daemon\", \"atuin-daemon\"]\nai = [\"atuin-ai\"]\nhex = [\"atuin-hex\"]\nclipboard = [\"arboard\"]\ncheck-update = [\"atuin-client/check-update\"]\n\n[dependencies]\natuin-ai = { path = \"../atuin-ai\", version = \"18.13.3\", optional = true, default-features = false }\natuin-client = { path = \"../atuin-client\", version = \"18.13.3\", optional = true, default-features = false }\natuin-common = { workspace = true }\natuin-dotfiles = { workspace = true }\natuin-history = { workspace = true }\natuin-daemon = { path = \"../atuin-daemon\", version = \"18.13.3\", optional = true, default-features = false }\natuin-hex = { path = \"../atuin-hex\", version = \"18.13.3\", optional = true, default-features = false }\natuin-scripts = { workspace = true }\natuin-kv = { workspace = true }\n\nlog = { workspace = true }\ntime = { workspace = true }\neyre = { workspace = true }\nindicatif = \"0.18.0\"\nserde = { workspace = true }\nserde_json = { workspace = true }\ncrossterm = { workspace = true, features = [\"use-dev-tty\"] }\nunicode-width = \"0.2\"\nitertools = { workspace = true }\ntokio = { workspace = true }\nasync-trait = { workspace = true }\ninterim = { workspace = true }\nclap = { workspace = true }\nclap_complete = \"4.5.8\"\nclap_complete_nushell = \"4.5.4\"\nfs-err = { workspace = true }\nfs4 = \"0.13.1\"\nrpassword = \"7.0\"\nsemver = { workspace = true }\nrustix = { workspace = true }\nruntime-format = \"0.1.3\"\ntiny-bip39 = \"2\"\nfutures-util = \"0.3\"\nfuzzy-matcher = \"0.3.7\"\ncolored = \"2.0.4\"\nopen = \"5\"\nratatui = { workspace = true }\ntracing = \"0.1\"\ntracing-subscriber = { workspace = true }\ntracing-appender = \"0.2\"\nuuid = { workspace = true }\nsysinfo = \"0.30.7\"\nregex = \"1.10.5\"\nnorm = { version = \"0.1.1\", features = [\"fzf-v2\"] }\natuin-nucleo-matcher = { workspace = true }\ntempfile = { workspace = true }\nshlex = \"1.3.0\"\n\n# settings editor with comment and relative ordering preservation\ntoml_edit = \"0.25.4\"\n\n[target.'cfg(any(target_os = \"windows\", target_os = \"macos\"))'.dependencies]\narboard = { version = \"3.4\", optional = true }\n\n[target.'cfg(target_os = \"linux\")'.dependencies]\narboard = { version = \"3.4\", optional = true, features = [\n  \"wayland-data-control\",\n] }\n\n[target.'cfg(unix)'.dependencies]\ndaemonize = \"0.5.0\"\n\n[dev-dependencies]\ntracing-tree = \"0.4\"\n\n# Integration tests in tests/ spin up a test server to verify sync functionality.\n# TODO: Consider moving these tests to atuin-server crate instead (client would become a dev dep there)\natuin-server = { workspace = true }\natuin-server-database = { workspace = true }\natuin-server-postgres = { workspace = true }\n"
  },
  {
    "path": "crates/atuin/LICENSE",
    "content": "MIT License\n\nCopyright (c) 2021 Ellie Huxtable\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
  },
  {
    "path": "crates/atuin/build.rs",
    "content": "use std::process::Command;\nfn main() {\n    let output = Command::new(\"git\").args([\"rev-parse\", \"HEAD\"]).output();\n\n    let sha = match output {\n        Ok(sha) => String::from_utf8(sha.stdout).unwrap(),\n        Err(_) => String::from(\"NO_GIT\"),\n    };\n\n    println!(\"cargo:rustc-env=GIT_HASH={sha}\");\n}\n"
  },
  {
    "path": "crates/atuin/src/command/client/account/change_password.rs",
    "content": "use clap::Parser;\nuse eyre::{Result, bail};\n\nuse atuin_client::{\n    auth::{self, MutateResponse},\n    settings::Settings,\n};\nuse rpassword::prompt_password;\n\n#[derive(Parser, Debug)]\npub struct Cmd {\n    #[clap(long, short)]\n    pub current_password: Option<String>,\n\n    #[clap(long, short)]\n    pub new_password: Option<String>,\n\n    /// The two-factor authentication code for your account, if any\n    #[clap(long, short)]\n    pub totp_code: Option<String>,\n}\n\nimpl Cmd {\n    pub async fn run(&self, settings: &Settings) -> Result<()> {\n        if !settings.logged_in().await? {\n            bail!(\"You are not logged in\");\n        }\n\n        let client = auth::auth_client(settings).await;\n\n        let current_password = self.current_password.clone().unwrap_or_else(|| {\n            prompt_password(\"Please enter the current password: \")\n                .expect(\"Failed to read from input\")\n        });\n\n        if current_password.is_empty() {\n            bail!(\"please provide the current password\");\n        }\n\n        let new_password = self.new_password.clone().unwrap_or_else(|| {\n            prompt_password(\"Please enter the new password: \").expect(\"Failed to read from input\")\n        });\n\n        if new_password.is_empty() {\n            bail!(\"please provide a new password\");\n        }\n\n        let mut totp_code = self.totp_code.clone();\n\n        loop {\n            let response = client\n                .change_password(&current_password, &new_password, totp_code.as_deref())\n                .await?;\n\n            match response {\n                MutateResponse::Success => break,\n                MutateResponse::TwoFactorRequired => {\n                    totp_code = Some(super::login::or_user_input(None, \"two-factor code\"));\n                }\n            }\n        }\n\n        println!(\"Account password successfully changed!\");\n\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "crates/atuin/src/command/client/account/delete.rs",
    "content": "use atuin_client::{\n    auth::{self, MutateResponse},\n    settings::Settings,\n};\nuse clap::Parser;\nuse eyre::{Result, bail};\n\nuse super::login::{or_user_input, read_user_password};\n\n#[derive(Parser, Debug)]\npub struct Cmd {\n    #[clap(long, short)]\n    pub password: Option<String>,\n\n    /// The two-factor authentication code for your account, if any\n    #[clap(long, short)]\n    pub totp_code: Option<String>,\n}\n\nimpl Cmd {\n    pub async fn run(&self, settings: &Settings) -> Result<()> {\n        if !settings.logged_in().await? {\n            bail!(\"You are not logged in\");\n        }\n\n        let client = auth::auth_client(settings).await;\n\n        let password = self.password.clone().unwrap_or_else(read_user_password);\n\n        if password.is_empty() {\n            bail!(\"please provide your password\");\n        }\n\n        let mut totp_code = self.totp_code.clone();\n\n        loop {\n            let response = client\n                .delete_account(&password, totp_code.as_deref())\n                .await?;\n\n            match response {\n                MutateResponse::Success => break,\n                MutateResponse::TwoFactorRequired => {\n                    totp_code = Some(or_user_input(None, \"two-factor code\"));\n                }\n            }\n        }\n\n        // Clean up sessions from meta store\n        let meta = Settings::meta_store().await?;\n        meta.delete_session().await?;\n        meta.delete_hub_session().await?;\n\n        println!(\"Your account is deleted\");\n\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "crates/atuin/src/command/client/account/link.rs",
    "content": "use eyre::{Result, bail};\n\nuse atuin_client::settings::Settings;\n\npub async fn run(settings: &Settings) -> Result<()> {\n    let meta = Settings::meta_store().await?;\n\n    let cli_token = meta.session_token().await?;\n    let hub_token = meta.hub_session_token().await?;\n\n    let Some(cli_token) = cli_token else {\n        bail!(\"No CLI session found. Please log in first with 'atuin login'.\");\n    };\n\n    let hub_address = settings.active_hub_endpoint().unwrap_or_default();\n\n    if hub_token.is_some() {\n        println!(\"Found both Hub and CLI sessions. Linking accounts...\");\n    } else {\n        println!(\"Found CLI session but no Hub session. Logging in to Hub first...\");\n\n        let session = atuin_client::hub::HubAuthSession::start(hub_address.as_ref()).await?;\n        println!(\"Open this URL to authenticate with Atuin Hub:\");\n        println!(\"{}\", session.auth_url);\n\n        let token = session\n            .wait_for_completion(\n                atuin_client::hub::DEFAULT_AUTH_TIMEOUT,\n                atuin_client::hub::DEFAULT_POLL_INTERVAL,\n            )\n            .await?;\n\n        atuin_client::hub::save_session(&token).await?;\n        println!(\"Hub authentication complete.\");\n    }\n\n    atuin_client::hub::link_account(hub_address.as_ref(), &cli_token).await?;\n    println!(\"Successfully linked CLI account to Hub.\");\n\n    Ok(())\n}\n"
  },
  {
    "path": "crates/atuin/src/command/client/account/login.rs",
    "content": "use std::{io, path::PathBuf};\n\nuse clap::Parser;\nuse eyre::{Context, Result, bail};\nuse tokio::{fs::File, io::AsyncWriteExt};\n\nuse atuin_client::{\n    auth::{self, AuthResponse},\n    encryption::{Key, decode_key, encode_key, load_key},\n    record::sqlite_store::SqliteStore,\n    record::store::Store,\n    settings::Settings,\n};\nuse rpassword::prompt_password;\n\n#[derive(Parser, Debug)]\npub struct Cmd {\n    #[clap(long, short)]\n    pub username: Option<String>,\n\n    #[clap(long, short)]\n    pub password: Option<String>,\n\n    /// The encryption key for your account\n    #[clap(long, short)]\n    pub key: Option<String>,\n\n    /// The two-factor authentication code for your account, if any\n    #[clap(long, short)]\n    pub totp_code: Option<String>,\n\n    #[clap(long, hide = true)]\n    pub from_registration: bool,\n}\n\nfn get_input() -> Result<String> {\n    let mut input = String::new();\n    io::stdin().read_line(&mut input)?;\n    Ok(input.trim_end_matches(&['\\r', '\\n'][..]).to_string())\n}\n\nimpl Cmd {\n    pub async fn run(&self, settings: &Settings, store: &SqliteStore) -> Result<()> {\n        if settings.logged_in().await? {\n            if settings.is_hub_sync() {\n                println!(\"You are authenticated with Atuin Hub.\");\n            } else {\n                println!(\"You are already logged in.\");\n            }\n            println!(\"Run 'atuin logout' to log out.\");\n            return Ok(());\n        }\n\n        if settings.is_hub_sync() {\n            self.run_hub_login(settings, store).await\n        } else {\n            self.run_legacy_login(settings, store).await\n        }\n    }\n\n    /// Hub login: use the browser OAuth flow unless all three flags\n    /// (username, password, key) were provided for headless/CI use.\n    async fn run_hub_login(&self, settings: &Settings, store: &SqliteStore) -> Result<()> {\n        let endpoint = settings.active_hub_endpoint().unwrap_or_default();\n\n        if let Some(username) = &self.username {\n            // Headless login via v0 API (for CI / scripting).\n            let client = auth::auth_client(settings).await;\n\n            self.prompt_and_store_key(settings, store).await?;\n\n            let password = self.password.clone().unwrap_or_else(read_user_password);\n            let mut totp_code = self.totp_code.clone();\n\n            let session = loop {\n                let response = client\n                    .login(username, &password, totp_code.as_deref())\n                    .await?;\n\n                match response {\n                    AuthResponse::Success { session } => break session,\n                    AuthResponse::TwoFactorRequired => {\n                        totp_code = Some(or_user_input(None, \"two-factor code\"));\n                    }\n                }\n            };\n\n            Settings::meta_store()\n                .await?\n                .save_hub_session(&session)\n                .await?;\n        } else {\n            // Interactive login via browser OAuth flow.\n            if self.from_registration {\n                load_key(settings)?;\n            } else {\n                self.prompt_and_store_key(settings, store).await?;\n            }\n\n            self.ensure_hub_session(settings, endpoint.as_ref()).await?;\n        }\n\n        // Silently attempt to link CLI account to Hub if one exists\n        if let Ok(cli_token) = settings.session_token().await\n            && let Err(e) = atuin_client::hub::link_account(endpoint.as_ref(), &cli_token).await\n        {\n            tracing::debug!(\"Could not link CLI account to Hub: {}\", e);\n        }\n\n        println!(\"Successfully authenticated with Atuin Hub.\");\n        Ok(())\n    }\n\n    /// Legacy login: always prompt for username/password interactively\n    /// (or accept them via flags).\n    async fn run_legacy_login(&self, settings: &Settings, store: &SqliteStore) -> Result<()> {\n        let username = or_user_input(self.username.clone(), \"username\");\n        let password = self.password.clone().unwrap_or_else(read_user_password);\n\n        self.prompt_and_store_key(settings, store).await?;\n\n        let client = auth::auth_client(settings).await;\n        let response = client.login(&username, &password, None).await?;\n\n        match response {\n            AuthResponse::Success { session } => {\n                Settings::meta_store().await?.save_session(&session).await?;\n            }\n            AuthResponse::TwoFactorRequired => {\n                // Legacy server doesn't support 2FA, so this shouldn't happen.\n                bail!(\"unexpected two-factor requirement from legacy server\");\n            }\n        }\n\n        println!(\"Logged in!\");\n        Ok(())\n    }\n\n    async fn ensure_hub_session(&self, _settings: &Settings, hub_address: &str) -> Result<()> {\n        tracing::info!(\"Authenticating with Atuin Hub...\");\n\n        let session = atuin_client::hub::HubAuthSession::start(hub_address).await?;\n        println!(\"Open this URL to continue authenticating with Atuin Hub:\");\n        println!(\"{}\", session.auth_url);\n\n        let token = session\n            .wait_for_completion(\n                atuin_client::hub::DEFAULT_AUTH_TIMEOUT,\n                atuin_client::hub::DEFAULT_POLL_INTERVAL,\n            )\n            .await?;\n\n        tracing::info!(\"Authentication complete, saving session token\");\n\n        atuin_client::hub::save_session(&token).await?;\n\n        Ok(())\n    }\n\n    async fn prompt_and_store_key(&self, settings: &Settings, store: &SqliteStore) -> Result<()> {\n        let key_path = settings.key_path.as_str();\n        let key_path = PathBuf::from(key_path);\n\n        println!(\"IMPORTANT\");\n        println!(\n            \"If you are already logged in on another machine, you must ensure that the key you use here is the same as the key you used there.\"\n        );\n        println!(\"You can find your key by running 'atuin key' on the other machine.\");\n        println!(\"Do not share this key with anyone.\");\n        println!(\"\\nRead more here: https://docs.atuin.sh/guide/sync/#login \\n\");\n\n        let key = or_user_input(\n            self.key.clone(),\n            \"encryption key [blank to use existing key file]\",\n        );\n\n        // if provided, the key may be EITHER base64, or a bip mnemonic\n        // try to normalize on base64\n        let key = if key.is_empty() {\n            key\n        } else {\n            // try parse the key as a mnemonic...\n            match bip39::Mnemonic::from_phrase(&key, bip39::Language::English) {\n                Ok(mnemonic) => encode_key(Key::from_slice(mnemonic.entropy()))?,\n                Err(err) => {\n                    match err {\n                        // assume they copied in the base64 key\n                        bip39::ErrorKind::InvalidWord(_) => key,\n                        bip39::ErrorKind::InvalidChecksum => {\n                            bail!(\"Key mnemonic is not valid\")\n                        }\n                        bip39::ErrorKind::InvalidKeysize(_)\n                        | bip39::ErrorKind::InvalidWordLength(_)\n                        | bip39::ErrorKind::InvalidEntropyLength(_, _) => {\n                            bail!(\"Key is not the correct length\")\n                        }\n                    }\n                }\n            }\n        };\n\n        if key.is_empty() {\n            if key_path.exists() {\n                let bytes = fs_err::read_to_string(&key_path).context(format!(\n                    \"Existing key file at '{}' could not be read\",\n                    key_path.to_string_lossy()\n                ))?;\n                if decode_key(bytes).is_err() {\n                    bail!(format!(\n                        \"The key in existing key file at '{}' is invalid\",\n                        key_path.to_string_lossy()\n                    ));\n                }\n            } else {\n                panic!(\n                    \"No key provided and no existing key file found. Please use 'atuin key' on your other machine, or recover your key from a backup\"\n                )\n            }\n        } else if !key_path.exists() {\n            if decode_key(key.clone()).is_err() {\n                bail!(\"The specified key is invalid\");\n            }\n\n            let mut file = File::create(&key_path).await?;\n            file.write_all(key.as_bytes()).await?;\n        } else {\n            // we now know that the user has logged in specifying a key, AND that the key path\n            // exists\n\n            // 1. check if the saved key and the provided key match. if so, nothing to do.\n            // 2. if not, re-encrypt the local history and overwrite the key\n            let current_key: [u8; 32] = load_key(settings)?.into();\n\n            let encoded = key.clone(); // gonna want to save it in a bit\n            let new_key: [u8; 32] = decode_key(key)\n                .context(\"Could not decode provided key; is not valid base64-encoded key\")?\n                .into();\n\n            if new_key != current_key {\n                println!(\"\\nRe-encrypting local store with new key\");\n\n                store.re_encrypt(&current_key, &new_key).await?;\n\n                println!(\"Writing new key\");\n                let mut file = File::create(&key_path).await?;\n                file.write_all(encoded.as_bytes()).await?;\n            }\n        }\n\n        Ok(())\n    }\n}\n\npub(super) fn or_user_input(value: Option<String>, name: &'static str) -> String {\n    value.unwrap_or_else(|| read_user_input(name))\n}\n\npub(super) fn read_user_password() -> String {\n    let password = prompt_password(\"Please enter password: \");\n    password.expect(\"Failed to read from input\")\n}\n\nfn read_user_input(name: &'static str) -> String {\n    eprint!(\"Please enter {name}: \");\n    get_input().expect(\"Failed to read from input\")\n}\n\n#[cfg(test)]\nmod tests {\n    use atuin_client::encryption::Key;\n\n    #[test]\n    fn mnemonic_round_trip() {\n        let key = Key::from([\n            3, 1, 4, 1, 5, 9, 2, 6, 5, 3, 5, 8, 9, 7, 9, 3, 2, 3, 8, 4, 6, 2, 6, 4, 3, 3, 8, 3, 2,\n            7, 9, 5,\n        ]);\n        let phrase = bip39::Mnemonic::from_entropy(&key, bip39::Language::English)\n            .unwrap()\n            .into_phrase();\n        let mnemonic = bip39::Mnemonic::from_phrase(&phrase, bip39::Language::English).unwrap();\n        assert_eq!(mnemonic.entropy(), key.as_slice());\n        assert_eq!(\n            phrase,\n            \"adapt amused able anxiety mother adapt beef gaze amount else seat alcohol cage lottery avoid scare alcohol cactus school avoid coral adjust catch pink\"\n        );\n    }\n}\n"
  },
  {
    "path": "crates/atuin/src/command/client/account/logout.rs",
    "content": "use eyre::Result;\n\npub async fn run() -> Result<()> {\n    atuin_client::logout::logout().await\n}\n"
  },
  {
    "path": "crates/atuin/src/command/client/account/register.rs",
    "content": "use clap::Parser;\nuse eyre::{Result, bail};\n\nuse super::login::or_user_input;\nuse atuin_client::{\n    auth::{self, AuthResponse},\n    record::sqlite_store::SqliteStore,\n    settings::Settings,\n};\n\n#[derive(Parser, Debug)]\npub struct Cmd {\n    #[clap(long, short)]\n    pub username: Option<String>,\n\n    #[clap(long, short)]\n    pub password: Option<String>,\n\n    #[clap(long, short)]\n    pub email: Option<String>,\n}\n\nimpl Cmd {\n    pub async fn run(&self, settings: &Settings, store: &SqliteStore) -> Result<()> {\n        if settings.logged_in().await? {\n            if settings.is_hub_sync() {\n                println!(\"You are already authenticated with Atuin Hub.\");\n            } else {\n                println!(\"You are already logged in.\");\n            }\n            println!(\"Run 'atuin logout' to log out.\");\n            return Ok(());\n        }\n\n        if settings.is_hub_sync() {\n            let required_for_headless = 3;\n            let provided = [\n                self.username.is_some(),\n                self.email.is_some(),\n                self.password.is_some(),\n            ]\n            .iter()\n            .filter(|&b| *b)\n            .count();\n            if provided < required_for_headless {\n                println!(\n                    \"Username, password, and email are all required for headless registration. Continuing with interactive registration.\\n\"\n                );\n            }\n\n            if let (Some(username), Some(email), Some(password)) =\n                (&self.username, &self.email, &self.password)\n            {\n                // Headless registration via v0 API (for CI / scripting).\n                let client = auth::auth_client(settings).await;\n\n                if password.is_empty() {\n                    bail!(\"please provide a password\");\n                }\n\n                let response = client.register(username, email, password).await?;\n\n                match response {\n                    AuthResponse::Success { session } => {\n                        Settings::meta_store()\n                            .await?\n                            .save_hub_session(&session)\n                            .await?;\n                    }\n                    AuthResponse::TwoFactorRequired => {\n                        bail!(\"unexpected two-factor requirement during registration\");\n                    }\n                }\n\n                let _key = atuin_client::encryption::load_key(settings)?;\n\n                println!(\n                    \"Registration successful! Please make a note of your key (run 'atuin key') and keep it safe.\"\n                );\n                println!(\n                    \"You will need it to log in on other devices, and we cannot help recover it if you lose it.\"\n                );\n            } else {\n                // Interactive registration: delegate to the browser OAuth flow.\n                // Registration on Hub happens on the website; the CLI just needs\n                // to authenticate afterwards.\n                super::login::Cmd {\n                    username: None,\n                    password: None,\n                    key: None,\n                    totp_code: None,\n                    from_registration: true,\n                }\n                .run(settings, store)\n                .await?;\n            }\n        } else {\n            // Legacy registration flow\n            println!(\"Registering for an Atuin Sync account\");\n\n            let username = or_user_input(self.username.clone(), \"username\");\n            let email = or_user_input(self.email.clone(), \"email\");\n            let password = self\n                .password\n                .clone()\n                .unwrap_or_else(super::login::read_user_password);\n\n            if password.is_empty() {\n                bail!(\"please provide a password\");\n            }\n\n            let session = atuin_client::api_client::register(\n                settings.sync_address.as_str(),\n                &username,\n                &email,\n                &password,\n            )\n            .await?;\n\n            let meta = Settings::meta_store().await?;\n            meta.save_session(&session.session).await?;\n\n            let _key = atuin_client::encryption::load_key(settings)?;\n\n            println!(\n                \"Registration successful! Please make a note of your key (run 'atuin key') and keep it safe.\"\n            );\n            println!(\n                \"You will need it to log in on other devices, and we cannot help recover it if you lose it.\"\n            );\n        }\n\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "crates/atuin/src/command/client/account.rs",
    "content": "use clap::{Args, Subcommand};\nuse eyre::Result;\n\nuse atuin_client::record::sqlite_store::SqliteStore;\nuse atuin_client::settings::Settings;\n\npub mod change_password;\npub mod delete;\npub mod link;\npub mod login;\npub mod logout;\npub mod register;\n\n#[derive(Args, Debug)]\npub struct Cmd {\n    #[command(subcommand)]\n    command: Commands,\n}\n\n#[derive(Subcommand, Debug)]\npub enum Commands {\n    /// Login to the configured server\n    Login(login::Cmd),\n\n    /// Register a new account\n    Register(register::Cmd),\n\n    /// Log out\n    Logout,\n\n    /// Delete your account, and all synced data\n    Delete(delete::Cmd),\n\n    /// Change your password\n    ChangePassword(change_password::Cmd),\n\n    /// Link your CLI sync account to your Hub account\n    Link,\n}\n\nimpl Cmd {\n    pub async fn run(self, settings: Settings, store: SqliteStore) -> Result<()> {\n        match self.command {\n            Commands::Login(l) => l.run(&settings, &store).await,\n            Commands::Register(r) => r.run(&settings, &store).await,\n            Commands::Logout => logout::run().await,\n            Commands::Delete(d) => d.run(&settings).await,\n            Commands::ChangePassword(c) => c.run(&settings).await,\n            Commands::Link => link::run(&settings).await,\n        }\n    }\n}\n"
  },
  {
    "path": "crates/atuin/src/command/client/daemon.rs",
    "content": "use std::fs::{self, File, OpenOptions};\nuse std::io::{ErrorKind, Write};\n#[cfg(unix)]\nuse std::os::unix::net::UnixStream as StdUnixStream;\nuse std::path::{Path, PathBuf};\nuse std::process::{Command, Stdio};\nuse std::time::{Duration, Instant};\n\nuse atuin_client::{\n    database::Sqlite, history::History, record::sqlite_store::SqliteStore, settings::Settings,\n};\nuse atuin_daemon::client::{DaemonClientErrorKind, HistoryClient, classify_error};\nuse clap::Subcommand;\n#[cfg(unix)]\nuse daemonize::Daemonize;\nuse eyre::{Result, WrapErr, bail, eyre};\nuse fs4::fs_std::FileExt;\nuse tokio::time::sleep;\n\n#[derive(clap::Args, Debug)]\npub struct Cmd {\n    /// Internal flag for daemonization\n    #[arg(long, hide = true)]\n    daemonize: bool,\n\n    /// Also write daemon logs to the console (useful for debugging)\n    #[arg(long)]\n    show_logs: bool,\n\n    #[command(subcommand)]\n    subcmd: Option<SubCmd>,\n}\n\n#[derive(Subcommand, Debug)]\n#[command(infer_subcommands = true)]\npub enum SubCmd {\n    /// Start the daemon server\n    Start {\n        #[arg(long, hide = true)]\n        daemonize: bool,\n\n        /// Also write daemon logs to the console (useful for debugging)\n        #[arg(long)]\n        show_logs: bool,\n\n        /// Force start: kill existing daemon process and reset the socket\n        #[arg(long)]\n        force: bool,\n    },\n\n    /// Show the daemon's current status\n    Status,\n\n    /// Stop the daemon gracefully\n    Stop,\n\n    /// Restart the daemon (stop, then start in background)\n    Restart,\n}\n\nimpl Cmd {\n    /// Returns `true` when the process should daemonize before creating the\n    /// async runtime or opening any database connections.\n    #[cfg(unix)]\n    pub fn should_daemonize(&self) -> bool {\n        match &self.subcmd {\n            Some(SubCmd::Start { daemonize, .. }) => *daemonize,\n            None => self.daemonize,\n            _ => false,\n        }\n    }\n\n    /// Returns `true` when logs should also be written to the console.\n    pub fn show_logs(&self) -> bool {\n        match &self.subcmd {\n            Some(SubCmd::Start { show_logs, .. }) => *show_logs,\n            None => self.show_logs,\n            _ => false,\n        }\n    }\n\n    pub async fn run(\n        self,\n        settings: Settings,\n        store: SqliteStore,\n        history_db: Sqlite,\n    ) -> Result<()> {\n        match self.subcmd {\n            None => {\n                eprintln!(\"Warning: `atuin daemon` is deprecated, use `atuin daemon start`\");\n                run(settings, store, history_db, false).await\n            }\n            Some(SubCmd::Start { force, .. }) => run(settings, store, history_db, force).await,\n            Some(SubCmd::Status) => status_cmd(&settings).await,\n            Some(SubCmd::Stop) => stop_cmd(&settings).await,\n            Some(SubCmd::Restart) => restart_cmd(&settings).await,\n        }\n    }\n}\n\nconst DAEMON_VERSION: &str = env!(\"CARGO_PKG_VERSION\");\nconst DAEMON_PROTOCOL_VERSION: u32 = 1;\nconst STARTUP_POLL: Duration = Duration::from_millis(40);\nconst LOCK_POLL: Duration = Duration::from_millis(20);\nconst LEGACY_DAEMON_RESTART_MESSAGE: &str = \"legacy daemon detected; restart daemon manually\";\n\nstruct PidfileGuard {\n    file: File,\n}\n\nimpl PidfileGuard {\n    fn acquire(path: &Path) -> Result<Self> {\n        let mut file = open_lock_file(path)?;\n\n        if !file.try_lock_exclusive()? {\n            bail!(\n                \"daemon already running (pidfile lock busy at {})\",\n                path.display()\n            );\n        }\n\n        file.set_len(0)\n            .wrap_err_with(|| format!(\"could not truncate daemon pidfile {}\", path.display()))?;\n        writeln!(file, \"{}\", std::process::id())\n            .and_then(|()| writeln!(file, \"{DAEMON_VERSION}\"))\n            .wrap_err_with(|| format!(\"could not write daemon pidfile {}\", path.display()))?;\n\n        Ok(Self { file })\n    }\n}\n\nimpl Drop for PidfileGuard {\n    fn drop(&mut self) {\n        let _ = self.file.unlock();\n    }\n}\n\nenum Probe {\n    Ready(HistoryClient),\n    NeedsRestart(String),\n    Unreachable(eyre::Report),\n}\n\nfn daemon_matches_expected(version: &str, protocol: u32) -> bool {\n    version == DAEMON_VERSION && protocol == DAEMON_PROTOCOL_VERSION\n}\n\nfn daemon_mismatch_message(version: &str, protocol: u32) -> String {\n    if protocol == DAEMON_PROTOCOL_VERSION {\n        format!(\"daemon is out of date: expected {DAEMON_VERSION}, got {version}\")\n    } else {\n        format!(\"daemon protocol mismatch: expected {DAEMON_PROTOCOL_VERSION}, got {protocol}\")\n    }\n}\n\nfn is_legacy_daemon_error(err: &eyre::Report) -> bool {\n    matches!(classify_error(err), DaemonClientErrorKind::Unimplemented)\n}\n\nfn should_retry_after_error(err: &eyre::Report) -> bool {\n    matches!(\n        classify_error(err),\n        DaemonClientErrorKind::Connect\n            | DaemonClientErrorKind::Unavailable\n            | DaemonClientErrorKind::Unimplemented\n    )\n}\n\nfn daemon_startup_lock_path(pidfile_path: &Path) -> PathBuf {\n    let mut os = pidfile_path.as_os_str().to_os_string();\n    os.push(\".startup.lock\");\n    PathBuf::from(os)\n}\n\nfn open_lock_file(path: &Path) -> Result<File> {\n    if let Some(parent) = path.parent() {\n        fs::create_dir_all(parent)\n            .wrap_err_with(|| format!(\"could not create lock directory {}\", parent.display()))?;\n    }\n\n    OpenOptions::new()\n        .read(true)\n        .write(true)\n        .create(true)\n        .truncate(false)\n        .open(path)\n        .wrap_err_with(|| format!(\"could not open lock file {}\", path.display()))\n}\n\nasync fn wait_for_lock(path: &Path, timeout: Duration) -> Result<File> {\n    let file = open_lock_file(path)?;\n    let start = Instant::now();\n\n    loop {\n        match file.try_lock_exclusive() {\n            Ok(true) => return Ok(file),\n            Ok(false) => {\n                if start.elapsed() >= timeout {\n                    bail!(\"timed out waiting for lock at {}\", path.display());\n                }\n\n                sleep(LOCK_POLL).await;\n            }\n            Err(err) => {\n                return Err(eyre!(\"could not lock {}: {err}\", path.display()));\n            }\n        }\n    }\n}\n\nasync fn wait_for_pidfile_available(path: &Path, timeout: Duration) -> Result<()> {\n    let file = wait_for_lock(path, timeout).await?;\n    file.unlock()\n        .wrap_err_with(|| format!(\"failed to unlock {}\", path.display()))?;\n    Ok(())\n}\n\nasync fn connect_client(settings: &Settings) -> Result<HistoryClient> {\n    HistoryClient::new(\n        #[cfg(not(unix))]\n        settings.daemon.tcp_port,\n        #[cfg(unix)]\n        settings.daemon.socket_path.clone(),\n    )\n    .await\n}\n\nasync fn probe(settings: &Settings) -> Probe {\n    let mut client = match connect_client(settings).await {\n        Ok(client) => client,\n        Err(err) => return Probe::Unreachable(err),\n    };\n\n    match client.status().await {\n        Ok(status) => {\n            if daemon_matches_expected(&status.version, status.protocol) {\n                Probe::Ready(client)\n            } else {\n                Probe::NeedsRestart(daemon_mismatch_message(&status.version, status.protocol))\n            }\n        }\n        Err(err) => Probe::Unreachable(err),\n    }\n}\n\nasync fn request_shutdown(settings: &Settings) {\n    if let Ok(mut client) = connect_client(settings).await {\n        let _ = client.shutdown().await;\n    }\n}\n\nfn spawn_daemon_process() -> Result<()> {\n    let exe = std::env::current_exe().wrap_err(\"could not locate atuin executable\")?;\n\n    let mut cmd = Command::new(exe);\n    cmd.arg(\"daemon\")\n        .arg(\"start\")\n        .stdin(Stdio::null())\n        .stdout(Stdio::null())\n        .stderr(Stdio::null());\n\n    #[cfg(unix)]\n    cmd.arg(\"--daemonize\");\n\n    cmd.spawn().wrap_err(\"failed to spawn daemon process\")?;\n\n    Ok(())\n}\n\nfn startup_timeout(settings: &Settings) -> Duration {\n    Duration::from_secs_f64(settings.local_timeout.max(0.5) + 2.0)\n}\n\n#[cfg(unix)]\nfn remove_stale_socket_if_present(settings: &Settings) -> Result<()> {\n    if settings.daemon.systemd_socket {\n        return Ok(());\n    }\n\n    let socket_path = Path::new(&settings.daemon.socket_path);\n    if !socket_path.exists() {\n        return Ok(());\n    }\n\n    match StdUnixStream::connect(socket_path) {\n        Ok(stream) => {\n            drop(stream);\n            Ok(())\n        }\n        Err(err) if err.kind() == ErrorKind::ConnectionRefused => {\n            fs::remove_file(socket_path).wrap_err_with(|| {\n                format!(\n                    \"failed to remove stale daemon socket {}\",\n                    socket_path.display()\n                )\n            })?;\n            Ok(())\n        }\n        Err(err) if err.kind() == ErrorKind::NotFound => Ok(()),\n        Err(_) => Ok(()),\n    }\n}\n\nasync fn wait_until_ready(settings: &Settings, timeout: Duration) -> Result<HistoryClient> {\n    let start = Instant::now();\n    let mut last_error = eyre!(\"daemon did not become ready\");\n\n    loop {\n        match probe(settings).await {\n            Probe::Ready(client) => return Ok(client),\n            Probe::NeedsRestart(reason) => {\n                last_error = eyre!(reason);\n            }\n            Probe::Unreachable(err) => {\n                if is_legacy_daemon_error(&err) {\n                    return Err(err.wrap_err(LEGACY_DAEMON_RESTART_MESSAGE));\n                }\n                last_error = err;\n            }\n        }\n\n        if start.elapsed() >= timeout {\n            return Err(last_error.wrap_err(format!(\n                \"timed out waiting for daemon startup after {}ms\",\n                timeout.as_millis()\n            )));\n        }\n\n        sleep(STARTUP_POLL).await;\n    }\n}\n\nfn ensure_autostart_supported(settings: &Settings) -> Result<()> {\n    #[cfg(unix)]\n    if settings.daemon.systemd_socket {\n        bail!(\n            \"daemon autostart is incompatible with `daemon.systemd_socket = true`; use systemd to manage the daemon\"\n        );\n    }\n    #[cfg(not(unix))]\n    let _ = settings;\n\n    Ok(())\n}\n\nasync fn restart_daemon(settings: &Settings) -> Result<HistoryClient> {\n    ensure_autostart_supported(settings)?;\n\n    let timeout = startup_timeout(settings);\n    let pidfile_path = PathBuf::from(&settings.daemon.pidfile_path);\n    let startup_lock_path = daemon_startup_lock_path(&pidfile_path);\n    let startup_lock = wait_for_lock(&startup_lock_path, timeout).await?;\n\n    match probe(settings).await {\n        Probe::Ready(client) => {\n            drop(startup_lock);\n            return Ok(client);\n        }\n        Probe::NeedsRestart(_) => {\n            request_shutdown(settings).await;\n        }\n        Probe::Unreachable(err) => {\n            if is_legacy_daemon_error(&err) {\n                return Err(err.wrap_err(LEGACY_DAEMON_RESTART_MESSAGE));\n            }\n        }\n    }\n\n    // This prevents rapid-fire hook invocations from racing daemon restart.\n    wait_for_pidfile_available(&pidfile_path, timeout).await?;\n\n    #[cfg(unix)]\n    remove_stale_socket_if_present(settings)?;\n\n    spawn_daemon_process()?;\n    let client = wait_until_ready(settings, timeout).await?;\n\n    drop(startup_lock);\n    Ok(client)\n}\n\nfn ensure_reply_compatible(settings: &Settings, version: &str, protocol: u32) -> Result<()> {\n    if daemon_matches_expected(version, protocol) {\n        return Ok(());\n    }\n\n    let message = daemon_mismatch_message(version, protocol);\n    if settings.daemon.autostart {\n        bail!(\"{message}\");\n    }\n\n    bail!(\"{message}. Enable `daemon.autostart = true` or restart the daemon manually\");\n}\n\npub async fn start_history(settings: &Settings, history: History) -> Result<String> {\n    match async {\n        connect_client(settings)\n            .await?\n            .start_history(history.clone())\n            .await\n    }\n    .await\n    {\n        Ok(resp) => {\n            if daemon_matches_expected(&resp.version, resp.protocol) {\n                return Ok(resp.id);\n            }\n\n            if !settings.daemon.autostart {\n                return Err(eyre!(\n                    \"{}. Enable `daemon.autostart = true` or restart the daemon manually\",\n                    daemon_mismatch_message(&resp.version, resp.protocol)\n                ));\n            }\n        }\n        Err(err) if !settings.daemon.autostart => return Err(err),\n        Err(err) if !should_retry_after_error(&err) => return Err(err),\n        Err(_) => {}\n    }\n\n    let resp = restart_daemon(settings)\n        .await?\n        .start_history(history)\n        .await?;\n    ensure_reply_compatible(settings, &resp.version, resp.protocol)?;\n    Ok(resp.id)\n}\n\npub async fn end_history(settings: &Settings, id: String, duration: u64, exit: i64) -> Result<()> {\n    match async {\n        connect_client(settings)\n            .await?\n            .end_history(id.clone(), duration, exit)\n            .await\n    }\n    .await\n    {\n        Ok(resp) => {\n            if daemon_matches_expected(&resp.version, resp.protocol) {\n                return Ok(());\n            }\n\n            if !settings.daemon.autostart {\n                return Err(eyre!(\n                    \"{}. Enable `daemon.autostart = true` or restart the daemon manually\",\n                    daemon_mismatch_message(&resp.version, resp.protocol)\n                ));\n            }\n\n            // End succeeded on the running daemon, so avoid replaying it.\n            // We only restart to make subsequent hook calls target the expected version.\n            let _ = restart_daemon(settings).await;\n            return Ok(());\n        }\n        Err(err) if !settings.daemon.autostart => return Err(err),\n        Err(err) if !should_retry_after_error(&err) => return Err(err),\n        Err(_) => {}\n    }\n\n    let resp = restart_daemon(settings)\n        .await?\n        .end_history(id, duration, exit)\n        .await?;\n    ensure_reply_compatible(settings, &resp.version, resp.protocol)?;\n    Ok(())\n}\n\nasync fn status_cmd(settings: &Settings) -> Result<()> {\n    match probe(settings).await {\n        Probe::Ready(mut client) => {\n            let status = client.status().await?;\n            println!(\"Daemon running\");\n            println!(\"  PID:      {}\", status.pid);\n            println!(\"  Version:  {}\", status.version);\n            println!(\"  Protocol: {}\", status.protocol);\n            println!(\"  Healthy:  {}\", status.healthy);\n            #[cfg(unix)]\n            println!(\"  Socket:   {}\", settings.daemon.socket_path);\n            #[cfg(not(unix))]\n            println!(\"  Port:     {}\", settings.daemon.tcp_port);\n        }\n        Probe::NeedsRestart(reason) => {\n            println!(\"Daemon running (needs restart)\");\n            println!(\"  Reason: {reason}\");\n        }\n        Probe::Unreachable(_) => {\n            println!(\"Daemon is not running\");\n        }\n    }\n\n    Ok(())\n}\n\nasync fn stop_cmd(settings: &Settings) -> Result<()> {\n    let Ok(mut client) = connect_client(settings).await else {\n        println!(\"Daemon is not running\");\n        return Ok(());\n    };\n\n    match client.shutdown().await {\n        Ok(true) => {\n            println!(\"Shutdown requested\");\n\n            let pidfile_path = PathBuf::from(&settings.daemon.pidfile_path);\n            let timeout = Duration::from_secs(5);\n            match wait_for_pidfile_available(&pidfile_path, timeout).await {\n                Ok(()) => println!(\"Daemon stopped\"),\n                Err(_) => println!(\"Daemon may still be shutting down\"),\n            }\n\n            Ok(())\n        }\n        Ok(false) => bail!(\"Daemon rejected shutdown request\"),\n        Err(err) => Err(err.wrap_err(\"Failed to send shutdown request\")),\n    }\n}\n\nasync fn restart_cmd(settings: &Settings) -> Result<()> {\n    // Stop if running\n    match probe(settings).await {\n        Probe::Ready(_) | Probe::NeedsRestart(_) => {\n            request_shutdown(settings).await;\n            println!(\"Stopping daemon...\");\n\n            let pidfile_path = PathBuf::from(&settings.daemon.pidfile_path);\n            let timeout = Duration::from_secs(5);\n            wait_for_pidfile_available(&pidfile_path, timeout)\n                .await\n                .wrap_err(\"Timed out waiting for old daemon to stop\")?;\n        }\n        Probe::Unreachable(_) => {\n            println!(\"No daemon running\");\n        }\n    }\n\n    #[cfg(unix)]\n    remove_stale_socket_if_present(settings)?;\n\n    spawn_daemon_process()?;\n    println!(\"Starting daemon...\");\n\n    let timeout = startup_timeout(settings);\n    let status = wait_until_ready(settings, timeout).await?.status().await?;\n\n    println!(\"Daemon restarted\");\n    println!(\"  PID:      {}\", status.pid);\n    println!(\"  Version:  {}\", status.version);\n\n    Ok(())\n}\n\n/// Daemonize the current process. Must be called before creating the tokio\n/// runtime or opening database connections, since `fork()` inside an async\n/// runtime corrupts its internal state.\n#[cfg(unix)]\npub fn daemonize_current_process() -> Result<()> {\n    let cwd =\n        std::env::current_dir().wrap_err(\"could not determine current directory for daemon\")?;\n\n    Daemonize::new()\n        .working_directory(cwd)\n        .start()\n        .wrap_err(\"failed to daemonize process\")?;\n\n    Ok(())\n}\n\nasync fn run(\n    settings: Settings,\n    store: SqliteStore,\n    history_db: Sqlite,\n    force: bool,\n) -> Result<()> {\n    if force {\n        force_cleanup(&settings);\n    }\n\n    let pidfile_path = PathBuf::from(&settings.daemon.pidfile_path);\n    let _pidfile_guard = PidfileGuard::acquire(&pidfile_path)?;\n\n    atuin_daemon::boot(settings, store, history_db).await?;\n\n    Ok(())\n}\n\n/// Force cleanup: kill existing daemon process and remove socket.\nfn force_cleanup(settings: &Settings) {\n    let pidfile_path = Path::new(&settings.daemon.pidfile_path);\n\n    // Read and kill the existing process if pidfile exists\n    if pidfile_path.exists() {\n        if let Ok(contents) = fs::read_to_string(pidfile_path)\n            && let Some(pid_str) = contents.lines().next()\n            && let Ok(pid) = pid_str.parse::<u32>()\n        {\n            kill_process(pid);\n            // Give it a moment to release resources\n            std::thread::sleep(Duration::from_millis(100));\n        }\n\n        // Remove the pidfile\n        if let Err(e) = fs::remove_file(pidfile_path)\n            && e.kind() != ErrorKind::NotFound\n        {\n            tracing::warn!(\"failed to remove pidfile: {e}\");\n        }\n    }\n\n    // Remove the socket file\n    #[cfg(unix)]\n    {\n        let socket_path = Path::new(&settings.daemon.socket_path);\n        if socket_path.exists()\n            && let Err(e) = fs::remove_file(socket_path)\n            && e.kind() != ErrorKind::NotFound\n        {\n            tracing::warn!(\"failed to remove socket: {e}\");\n        }\n    }\n}\n\n/// Kill a process by PID.\n#[cfg(unix)]\nfn kill_process(pid: u32) {\n    // Use kill command to send SIGTERM for graceful shutdown\n    let _ = Command::new(\"kill\")\n        .args([\"-TERM\", &pid.to_string()])\n        .stdout(Stdio::null())\n        .stderr(Stdio::null())\n        .status();\n}\n\n/// Kill a process by PID.\n#[cfg(not(unix))]\nfn kill_process(pid: u32) {\n    // On Windows, use taskkill\n    let _ = Command::new(\"taskkill\")\n        .args([\"/PID\", &pid.to_string(), \"/F\"])\n        .stdout(Stdio::null())\n        .stderr(Stdio::null())\n        .status();\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn test_version_matches() {\n        assert!(daemon_matches_expected(\n            DAEMON_VERSION,\n            DAEMON_PROTOCOL_VERSION\n        ));\n    }\n\n    #[test]\n    fn test_version_mismatch() {\n        assert!(!daemon_matches_expected(\"0.0.0\", DAEMON_PROTOCOL_VERSION));\n        assert!(!daemon_matches_expected(DAEMON_VERSION, 999));\n        assert!(!daemon_matches_expected(\"0.0.0\", 999));\n    }\n\n    #[test]\n    fn test_mismatch_message_version() {\n        let msg = daemon_mismatch_message(\"0.0.0\", DAEMON_PROTOCOL_VERSION);\n        assert!(msg.contains(\"out of date\"), \"got: {msg}\");\n        assert!(msg.contains(\"0.0.0\"));\n        assert!(msg.contains(DAEMON_VERSION));\n    }\n\n    #[test]\n    fn test_mismatch_message_protocol() {\n        let msg = daemon_mismatch_message(DAEMON_VERSION, 999);\n        assert!(msg.contains(\"protocol mismatch\"), \"got: {msg}\");\n    }\n\n    #[test]\n    fn test_startup_lock_path() {\n        let pidfile = Path::new(\"/tmp/atuin-daemon.pid\");\n        let lock = daemon_startup_lock_path(pidfile);\n        assert_eq!(lock, PathBuf::from(\"/tmp/atuin-daemon.pid.startup.lock\"));\n    }\n\n    #[test]\n    fn test_pidfile_guard_acquire_and_drop() {\n        let tmp = tempfile::tempdir().unwrap();\n        let pidfile = tmp.path().join(\"daemon.pid\");\n\n        {\n            let _guard = PidfileGuard::acquire(&pidfile).unwrap();\n            // Guard holds an exclusive lock — on Windows other handles cannot\n            // read the file, so we verify contents after the guard is dropped.\n        }\n\n        let contents = std::fs::read_to_string(&pidfile).unwrap();\n        let lines: Vec<&str> = contents.lines().collect();\n        assert_eq!(lines.len(), 2);\n        assert_eq!(lines[0], std::process::id().to_string());\n        assert_eq!(lines[1], DAEMON_VERSION);\n\n        // After guard is dropped, lock should be released — acquiring again must succeed.\n        let _guard2 = PidfileGuard::acquire(&pidfile).unwrap();\n    }\n\n    #[test]\n    fn test_pidfile_guard_prevents_double_acquire() {\n        let tmp = tempfile::tempdir().unwrap();\n        let pidfile = tmp.path().join(\"daemon.pid\");\n\n        let _guard = PidfileGuard::acquire(&pidfile).unwrap();\n        let result = PidfileGuard::acquire(&pidfile);\n        assert!(result.is_err());\n    }\n}\n"
  },
  {
    "path": "crates/atuin/src/command/client/default_config.rs",
    "content": "use atuin_client::settings::Settings;\n\npub fn run() {\n    println!(\"{}\", Settings::example_config());\n}\n"
  },
  {
    "path": "crates/atuin/src/command/client/doctor.rs",
    "content": "use std::process::Command;\nuse std::{env, str::FromStr};\n\nuse atuin_client::database::Sqlite;\nuse atuin_client::settings::Settings;\nuse atuin_common::shell::{Shell, shell_name};\nuse atuin_common::utils;\nuse colored::Colorize;\nuse eyre::Result;\nuse serde::Serialize;\n\nuse sysinfo::{Disks, System, get_current_pid};\n\n#[derive(Debug, Serialize)]\nstruct ShellInfo {\n    pub name: String,\n\n    // best-effort, not supported on all OSes\n    pub default: String,\n\n    // Detect some shell plugins that the user has installed.\n    // I'm just going to start with preexec/blesh\n    pub plugins: Vec<String>,\n\n    // The preexec framework used in the current session, if Atuin is loaded.\n    pub preexec: Option<String>,\n}\n\nimpl ShellInfo {\n    // HACK ALERT!\n    // Many of the shell vars we need to detect are not exported :(\n    // So, we're going to run a interactive session and directly check the\n    // variable.  There's a chance this won't work, so it should not be fatal.\n    //\n    // Every shell we support handles `shell -ic 'command'`\n    fn shellvar_exists(shell: &str, var: &str) -> bool {\n        let cmd = Command::new(shell)\n            .args([\n                \"-ic\",\n                format!(\"[ -z ${var} ] || echo ATUIN_DOCTOR_ENV_FOUND\").as_str(),\n            ])\n            .output()\n            .map_or(String::new(), |v| {\n                let out = v.stdout;\n                String::from_utf8(out).unwrap_or_default()\n            });\n\n        cmd.contains(\"ATUIN_DOCTOR_ENV_FOUND\")\n    }\n\n    fn detect_preexec_framework(shell: &str) -> Option<String> {\n        if env::var(\"ATUIN_SESSION\").ok().is_none() {\n            None\n        } else if shell.starts_with(\"bash\") || shell == \"sh\" {\n            env::var(\"ATUIN_PREEXEC_BACKEND\")\n                .ok()\n                .filter(|value| !value.is_empty())\n                .and_then(|atuin_preexec_backend| {\n                    atuin_preexec_backend.rfind(':').and_then(|pos_colon| {\n                        u32::from_str(&atuin_preexec_backend[..pos_colon])\n                            .ok()\n                            .is_some_and(|preexec_shlvl| {\n                                env::var(\"SHLVL\")\n                                    .ok()\n                                    .and_then(|shlvl| u32::from_str(&shlvl).ok())\n                                    .is_some_and(|shlvl| shlvl == preexec_shlvl)\n                            })\n                            .then(|| atuin_preexec_backend[pos_colon + 1..].to_string())\n                    })\n                })\n        } else {\n            Some(\"built-in\".to_string())\n        }\n    }\n\n    fn validate_plugin_blesh(\n        _shell: &str,\n        shell_process: &sysinfo::Process,\n        ble_session_id: &str,\n    ) -> Option<String> {\n        ble_session_id\n            .split('/')\n            .nth(1)\n            .and_then(|field| u32::from_str(field).ok())\n            .filter(|&blesh_pid| blesh_pid == shell_process.pid().as_u32())\n            .map(|_| \"blesh\".to_string())\n    }\n\n    pub fn plugins(shell: &str, shell_process: &sysinfo::Process) -> Vec<String> {\n        // consider a different detection approach if there are plugins\n        // that don't set shell vars\n\n        enum PluginShellType {\n            Any,\n            Bash,\n\n            // Note: these are currently unused\n            #[allow(dead_code)]\n            Zsh,\n            #[allow(dead_code)]\n            Fish,\n            #[allow(dead_code)]\n            Nushell,\n            #[allow(dead_code)]\n            Xonsh,\n        }\n\n        enum PluginProbeType {\n            EnvironmentVariable(&'static str),\n            InteractiveShellVariable(&'static str),\n        }\n\n        type PluginValidator = fn(&str, &sysinfo::Process, &str) -> Option<String>;\n\n        let plugin_list: [(\n            &str,\n            PluginShellType,\n            PluginProbeType,\n            Option<PluginValidator>,\n        ); 3] = [\n            (\n                \"atuin\",\n                PluginShellType::Any,\n                PluginProbeType::EnvironmentVariable(\"ATUIN_SESSION\"),\n                None,\n            ),\n            (\n                \"blesh\",\n                PluginShellType::Bash,\n                PluginProbeType::EnvironmentVariable(\"BLE_SESSION_ID\"),\n                Some(Self::validate_plugin_blesh),\n            ),\n            (\n                \"bash-preexec\",\n                PluginShellType::Bash,\n                PluginProbeType::InteractiveShellVariable(\"bash_preexec_imported\"),\n                None,\n            ),\n        ];\n\n        plugin_list\n            .into_iter()\n            .filter(|(_, shell_type, _, _)| match shell_type {\n                PluginShellType::Any => true,\n                PluginShellType::Bash => shell.starts_with(\"bash\") || shell == \"sh\",\n                PluginShellType::Zsh => shell.starts_with(\"zsh\"),\n                PluginShellType::Fish => shell.starts_with(\"fish\"),\n                PluginShellType::Nushell => shell.starts_with(\"nu\"),\n                PluginShellType::Xonsh => shell.starts_with(\"xonsh\"),\n            })\n            .filter_map(|(plugin, _, probe_type, validator)| -> Option<String> {\n                match probe_type {\n                    PluginProbeType::EnvironmentVariable(env) => {\n                        env::var(env).ok().filter(|value| !value.is_empty())\n                    }\n                    PluginProbeType::InteractiveShellVariable(shellvar) => {\n                        ShellInfo::shellvar_exists(shell, shellvar).then_some(String::default())\n                    }\n                }\n                .and_then(|value| {\n                    validator.map_or_else(\n                        || Some(plugin.to_string()),\n                        |validator| validator(shell, shell_process, &value),\n                    )\n                })\n            })\n            .collect()\n    }\n\n    pub fn new() -> Self {\n        // TODO: rework to use atuin_common::Shell\n\n        let sys = System::new_all();\n\n        let process = sys\n            .process(get_current_pid().expect(\"Failed to get current PID\"))\n            .expect(\"Process with current pid does not exist\");\n\n        let parent = sys\n            .process(process.parent().expect(\"Atuin running with no parent!\"))\n            .expect(\"Process with parent pid does not exist\");\n\n        let name = shell_name(Some(parent));\n\n        let plugins = ShellInfo::plugins(name.as_str(), parent);\n\n        let default = Shell::default_shell().unwrap_or(Shell::Unknown).to_string();\n\n        let preexec = Self::detect_preexec_framework(name.as_str());\n\n        Self {\n            name,\n            default,\n            plugins,\n            preexec,\n        }\n    }\n}\n\n#[derive(Debug, Serialize)]\nstruct DiskInfo {\n    pub name: String,\n    pub filesystem: String,\n}\n\n#[derive(Debug, Serialize)]\nstruct SystemInfo {\n    pub os: String,\n\n    pub arch: String,\n\n    pub version: String,\n    pub disks: Vec<DiskInfo>,\n}\n\nimpl SystemInfo {\n    pub fn new() -> Self {\n        let disks = Disks::new_with_refreshed_list();\n        let disks = disks\n            .list()\n            .iter()\n            .map(|d| DiskInfo {\n                name: d.name().to_os_string().into_string().unwrap(),\n                filesystem: d.file_system().to_os_string().into_string().unwrap(),\n            })\n            .collect();\n\n        Self {\n            os: System::name().unwrap_or_else(|| \"unknown\".to_string()),\n            arch: System::cpu_arch().unwrap_or_else(|| \"unknown\".to_string()),\n            version: System::os_version().unwrap_or_else(|| \"unknown\".to_string()),\n            disks,\n        }\n    }\n}\n\n#[derive(Debug, Serialize)]\nstruct SyncInfo {\n    /// Whether the main Atuin sync server is in use\n    /// I'm just calling it Atuin Cloud for lack of a better name atm\n    pub cloud: bool,\n    pub records: bool,\n    pub auto_sync: bool,\n\n    pub last_sync: String,\n}\n\nimpl SyncInfo {\n    pub async fn new(settings: &Settings) -> Self {\n        Self {\n            cloud: settings.is_hub_sync(),\n            auto_sync: settings.auto_sync,\n            records: settings.sync.records,\n            last_sync: Settings::last_sync()\n                .await\n                .map_or_else(|_| \"no last sync\".to_string(), |v| v.to_string()),\n        }\n    }\n}\n\n#[derive(Debug)]\nstruct SettingPaths {\n    db: String,\n    record_store: String,\n    key: String,\n}\n\nimpl SettingPaths {\n    pub fn new(settings: &Settings) -> Self {\n        Self {\n            db: settings.db_path.clone(),\n            record_store: settings.record_store_path.clone(),\n            key: settings.key_path.clone(),\n        }\n    }\n\n    pub fn verify(&self) {\n        let paths = vec![\n            (\"ATUIN_DB_PATH\", &self.db),\n            (\"ATUIN_RECORD_STORE\", &self.record_store),\n            (\"ATUIN_KEY\", &self.key),\n        ];\n\n        for (path_env_var, path) in paths {\n            if utils::broken_symlink(path) {\n                eprintln!(\n                    \"{path} (${path_env_var}) is a broken symlink. This may cause issues with Atuin.\"\n                );\n            }\n        }\n    }\n}\n\n#[derive(Debug, Serialize)]\nstruct AtuinInfo {\n    pub version: String,\n    pub commit: String,\n\n    /// Whether the main Atuin sync server is in use\n    /// I'm just calling it Atuin Cloud for lack of a better name atm\n    pub sync: Option<SyncInfo>,\n\n    pub sqlite_version: String,\n\n    #[serde(skip)] // probably unnecessary to expose this\n    pub setting_paths: SettingPaths,\n}\n\nimpl AtuinInfo {\n    pub async fn new(settings: &Settings) -> Self {\n        let logged_in = settings.logged_in().await.unwrap_or(false);\n\n        let sync = if logged_in {\n            Some(SyncInfo::new(settings).await)\n        } else {\n            None\n        };\n\n        let sqlite_version = match Sqlite::new(\"sqlite::memory:\", 0.1).await {\n            Ok(db) => db\n                .sqlite_version()\n                .await\n                .unwrap_or_else(|_| \"unknown\".to_string()),\n            Err(_) => \"error\".to_string(),\n        };\n\n        Self {\n            version: crate::VERSION.to_string(),\n            commit: crate::SHA.to_string(),\n            sync,\n            sqlite_version,\n            setting_paths: SettingPaths::new(settings),\n        }\n    }\n}\n\n#[derive(Debug, Serialize)]\nstruct DoctorDump {\n    pub atuin: AtuinInfo,\n    pub shell: ShellInfo,\n    pub system: SystemInfo,\n}\n\nimpl DoctorDump {\n    pub async fn new(settings: &Settings) -> Self {\n        Self {\n            atuin: AtuinInfo::new(settings).await,\n            shell: ShellInfo::new(),\n            system: SystemInfo::new(),\n        }\n    }\n}\n\nfn checks(info: &DoctorDump) {\n    println!(); // spacing\n    //\n    let zfs_error = \"[Filesystem] ZFS is known to have some issues with SQLite. Atuin uses SQLite heavily. If you are having poor performance, there are some workarounds here: https://github.com/atuinsh/atuin/issues/952\".bold().red();\n    let bash_plugin_error = \"[Shell] If you are using Bash, Atuin requires that either bash-preexec or ble.sh (>= 0.4) be installed. An older ble.sh may not be detected. so ignore this if you have ble.sh >= 0.4 set up! Read more here: https://docs.atuin.sh/guide/installation/#bash\".bold().red();\n    let blesh_integration_error = \"[Shell] Atuin and ble.sh seem to be loaded in the session, but the integration does not seem to be working. Please check the setup in .bashrc.\".bold().red();\n\n    // ZFS: https://github.com/atuinsh/atuin/issues/952\n    if info.system.disks.iter().any(|d| d.filesystem == \"zfs\") {\n        println!(\"{zfs_error}\");\n    }\n\n    info.atuin.setting_paths.verify();\n\n    // Shell\n    if info.shell.name == \"bash\" {\n        if !info\n            .shell\n            .plugins\n            .iter()\n            .any(|p| p == \"blesh\" || p == \"bash-preexec\")\n        {\n            println!(\"{bash_plugin_error}\");\n        }\n\n        if info.shell.plugins.iter().any(|plugin| plugin == \"atuin\")\n            && info.shell.plugins.iter().any(|plugin| plugin == \"blesh\")\n            && info.shell.preexec.as_ref().is_some_and(|val| val == \"none\")\n        {\n            println!(\"{blesh_integration_error}\");\n        }\n    }\n}\n\npub async fn run(settings: &Settings) -> Result<()> {\n    println!(\"{}\", \"Atuin Doctor\".bold());\n    println!(\"Checking for diagnostics\");\n    let dump = DoctorDump::new(settings).await;\n\n    checks(&dump);\n\n    let dump = serde_json::to_string_pretty(&dump)?;\n\n    println!(\"\\nPlease include the output below with any bug reports or issues\\n\");\n    println!(\"{dump}\");\n\n    Ok(())\n}\n"
  },
  {
    "path": "crates/atuin/src/command/client/dotfiles/alias.rs",
    "content": "use clap::{Subcommand, ValueEnum};\nuse eyre::{Context, Result, eyre};\n\nuse atuin_client::{encryption, record::sqlite_store::SqliteStore, settings::Settings};\n\nuse atuin_dotfiles::{shell::Alias, store::AliasStore};\n\n#[derive(Clone, Copy, Debug, Default, ValueEnum)]\npub enum SortBy {\n    /// Sort by alias name\n    #[default]\n    Name,\n    /// Sort by alias value\n    Value,\n}\n\n#[derive(Subcommand, Debug)]\n#[command(infer_subcommands = true)]\npub enum Cmd {\n    /// Set an alias\n    Set { name: String, value: String },\n\n    /// Delete an alias\n    Delete { name: String },\n\n    /// List all aliases\n    List {\n        /// Sort results by field\n        #[arg(long, value_enum, default_value_t = SortBy::Name)]\n        sort_by: SortBy,\n\n        /// Sort in reverse (descending) order\n        #[arg(long, short)]\n        reverse: bool,\n\n        /// Filter aliases by name (substring match)\n        #[arg(long, short)]\n        name: Option<String>,\n\n        /// Filter aliases by value (substring match)\n        #[arg(long, short)]\n        value: Option<String>,\n    },\n\n    /// Delete all aliases\n    Clear,\n    // There are too many edge cases to parse at the moment. Disable for now.\n    // Import,\n}\n\nimpl Cmd {\n    async fn set(&self, store: &AliasStore, name: String, value: String) -> Result<()> {\n        let illegal_char = regex::Regex::new(\"[ \\t\\n&();<>|\\\\\\\"'`$/]\").unwrap();\n        if illegal_char.is_match(name.as_str()) {\n            return Err(eyre!(\"Illegal character in alias name\"));\n        }\n\n        let aliases = store.aliases().await?;\n        let found: Vec<Alias> = aliases.into_iter().filter(|a| a.name == name).collect();\n\n        if found.is_empty() {\n            println!(\"Aliasing '{name}={value}'.\");\n        } else {\n            println!(\n                \"Overwriting alias '{name}={}' with '{name}={value}'.\",\n                found[0].value\n            );\n        }\n\n        store.set(&name, &value).await?;\n\n        Ok(())\n    }\n\n    async fn list(\n        &self,\n        store: &AliasStore,\n        sort_by: SortBy,\n        reverse: bool,\n        name_filter: Option<String>,\n        value_filter: Option<String>,\n    ) -> Result<()> {\n        let mut aliases = store.aliases().await?;\n\n        // Apply filters\n        if let Some(ref name_pattern) = name_filter {\n            let pattern = name_pattern.to_lowercase();\n            aliases.retain(|a| a.name.to_lowercase().contains(&pattern));\n        }\n        if let Some(ref value_pattern) = value_filter {\n            let pattern = value_pattern.to_lowercase();\n            aliases.retain(|a| a.value.to_lowercase().contains(&pattern));\n        }\n\n        // Apply sorting\n        match sort_by {\n            SortBy::Name => {\n                aliases.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));\n            }\n            SortBy::Value => {\n                aliases.sort_by(|a, b| a.value.to_lowercase().cmp(&b.value.to_lowercase()));\n            }\n        }\n\n        // Apply reverse if requested\n        if reverse {\n            aliases.reverse();\n        }\n\n        for i in aliases {\n            println!(\"{}={}\", i.name, i.value);\n        }\n\n        Ok(())\n    }\n\n    async fn clear(&self, store: &AliasStore) -> Result<()> {\n        let aliases = store.aliases().await?;\n\n        for i in aliases {\n            self.delete(store, i.name).await?;\n        }\n\n        Ok(())\n    }\n\n    async fn delete(&self, store: &AliasStore, name: String) -> Result<()> {\n        let mut aliases = store.aliases().await?.into_iter();\n        if let Some(alias) = aliases.find(|alias| alias.name == name) {\n            println!(\"Deleting '{name}={}'.\", alias.value);\n            store.delete(&name).await?;\n        } else {\n            eprintln!(\"Cannot delete '{name}': Alias not set.\");\n        }\n        Ok(())\n    }\n\n    /*\n    async fn import(&self, store: &AliasStore) -> Result<()> {\n        let aliases = atuin_dotfiles::shell::import_aliases(store).await?;\n\n        for i in aliases {\n            println!(\"Importing {}={}\", i.name, i.value);\n        }\n\n        Ok(())\n    }\n    */\n\n    pub async fn run(&self, settings: &Settings, store: SqliteStore) -> Result<()> {\n        if !settings.dotfiles.enabled {\n            eprintln!(\n                \"Dotfiles are not enabled. Add\\n\\n[dotfiles]\\nenabled = true\\n\\nto your configuration file to enable them.\\n\"\n            );\n            eprintln!(\"The default configuration file is located at ~/.config/atuin/config.toml.\");\n            return Ok(());\n        }\n\n        let encryption_key: [u8; 32] = encryption::load_key(settings)\n            .context(\"could not load encryption key\")?\n            .into();\n        let host_id = Settings::host_id().await?;\n\n        let alias_store = AliasStore::new(store, host_id, encryption_key);\n\n        match self {\n            Self::Set { name, value } => self.set(&alias_store, name.clone(), value.clone()).await,\n            Self::Delete { name } => self.delete(&alias_store, name.clone()).await,\n            Self::List {\n                sort_by,\n                reverse,\n                name,\n                value,\n            } => {\n                self.list(\n                    &alias_store,\n                    *sort_by,\n                    *reverse,\n                    name.clone(),\n                    value.clone(),\n                )\n                .await\n            }\n            Self::Clear => self.clear(&alias_store).await,\n        }\n    }\n}\n"
  },
  {
    "path": "crates/atuin/src/command/client/dotfiles/var.rs",
    "content": "use clap::{Subcommand, ValueEnum};\nuse eyre::{Context, Result};\n\nuse atuin_client::{encryption, record::sqlite_store::SqliteStore, settings::Settings};\n\nuse atuin_dotfiles::{shell::Var, store::var::VarStore};\n\n#[derive(Clone, Copy, Debug, Default, ValueEnum)]\npub enum SortBy {\n    /// Sort by variable name\n    #[default]\n    Name,\n    /// Sort by variable value\n    Value,\n}\n\n#[derive(Subcommand, Debug)]\n#[command(infer_subcommands = true)]\npub enum Cmd {\n    /// Set a variable\n    Set {\n        name: String,\n        value: String,\n\n        #[clap(long, short, action)]\n        no_export: bool,\n    },\n\n    /// Delete a variable\n    Delete { name: String },\n\n    /// List all variables\n    List {\n        /// Sort results by field\n        #[arg(long, value_enum, default_value_t = SortBy::Name)]\n        sort_by: SortBy,\n\n        /// Sort in reverse (descending) order\n        #[arg(long, short)]\n        reverse: bool,\n\n        /// Filter variables by name (substring match)\n        #[arg(long, short)]\n        name: Option<String>,\n\n        /// Filter variables by value (substring match)\n        #[arg(long, short)]\n        value: Option<String>,\n\n        /// Show only exported variables\n        #[arg(long, conflicts_with = \"shell_only\")]\n        exports_only: bool,\n\n        /// Show only non-exported (shell) variables\n        #[arg(long, conflicts_with = \"exports_only\")]\n        shell_only: bool,\n    },\n}\n\nimpl Cmd {\n    async fn set(&self, store: VarStore, name: String, value: String, export: bool) -> Result<()> {\n        let vars = store.vars().await?;\n        let found: Vec<Var> = vars.into_iter().filter(|a| a.name == name).collect();\n        let show_export = if export { \"export \" } else { \"\" };\n\n        if found.is_empty() {\n            println!(\"Setting '{show_export}{name}={value}'.\");\n        } else {\n            println!(\n                \"Overwriting var '{show_export}{name}={}' with '{name}={value}'.\",\n                found[0].value\n            );\n        }\n\n        store.set(&name, &value, export).await?;\n\n        Ok(())\n    }\n\n    #[allow(clippy::too_many_arguments)]\n    async fn list(\n        &self,\n        store: VarStore,\n        sort_by: SortBy,\n        reverse: bool,\n        name_filter: Option<String>,\n        value_filter: Option<String>,\n        exports_only: bool,\n        shell_only: bool,\n    ) -> Result<()> {\n        let mut vars = store.vars().await?;\n\n        // Apply export/shell filters\n        if exports_only {\n            vars.retain(|v| v.export);\n        }\n        if shell_only {\n            vars.retain(|v| !v.export);\n        }\n\n        // Apply name/value filters\n        if let Some(ref name_pattern) = name_filter {\n            let pattern = name_pattern.to_lowercase();\n            vars.retain(|v| v.name.to_lowercase().contains(&pattern));\n        }\n        if let Some(ref value_pattern) = value_filter {\n            let pattern = value_pattern.to_lowercase();\n            vars.retain(|v| v.value.to_lowercase().contains(&pattern));\n        }\n\n        // Apply sorting\n        match sort_by {\n            SortBy::Name => {\n                vars.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));\n            }\n            SortBy::Value => {\n                vars.sort_by(|a, b| a.value.to_lowercase().cmp(&b.value.to_lowercase()));\n            }\n        }\n\n        // Apply reverse if requested\n        if reverse {\n            vars.reverse();\n        }\n\n        for i in vars {\n            if i.export {\n                println!(\"export {}={}\", i.name, i.value);\n            } else {\n                println!(\"{}={}\", i.name, i.value);\n            }\n        }\n\n        Ok(())\n    }\n\n    async fn delete(&self, store: VarStore, name: String) -> Result<()> {\n        let mut vars = store.vars().await?.into_iter();\n\n        if let Some(var) = vars.find(|var| var.name == name) {\n            println!(\"Deleting '{name}={}'.\", var.value);\n            store.delete(&name).await?;\n        } else {\n            eprintln!(\"Cannot delete '{name}': Var not set.\");\n        }\n\n        Ok(())\n    }\n\n    pub async fn run(&self, settings: &Settings, store: SqliteStore) -> Result<()> {\n        if !settings.dotfiles.enabled {\n            eprintln!(\n                \"Dotfiles are not enabled. Add\\n\\n[dotfiles]\\nenabled = true\\n\\nto your configuration file to enable them.\\n\"\n            );\n            eprintln!(\"The default configuration file is located at ~/.config/atuin/config.toml.\");\n            return Ok(());\n        }\n\n        let encryption_key: [u8; 32] = encryption::load_key(settings)\n            .context(\"could not load encryption key\")?\n            .into();\n        let host_id = Settings::host_id().await?;\n\n        let var_store = VarStore::new(store, host_id, encryption_key);\n\n        match self {\n            Self::Set {\n                name,\n                value,\n                no_export,\n            } => {\n                self.set(var_store, name.clone(), value.clone(), !no_export)\n                    .await\n            }\n            Self::Delete { name } => self.delete(var_store, name.clone()).await,\n            Self::List {\n                sort_by,\n                reverse,\n                name,\n                value,\n                exports_only,\n                shell_only,\n            } => {\n                self.list(\n                    var_store,\n                    *sort_by,\n                    *reverse,\n                    name.clone(),\n                    value.clone(),\n                    *exports_only,\n                    *shell_only,\n                )\n                .await\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "crates/atuin/src/command/client/dotfiles.rs",
    "content": "use clap::Subcommand;\nuse eyre::Result;\n\nuse atuin_client::{record::sqlite_store::SqliteStore, settings::Settings};\n\nmod alias;\nmod var;\n\n#[derive(Subcommand, Debug)]\n#[command(infer_subcommands = true)]\npub enum Cmd {\n    /// Manage shell aliases with Atuin\n    #[command(subcommand)]\n    Alias(alias::Cmd),\n\n    /// Manage shell and environment variables with Atuin\n    #[command(subcommand)]\n    Var(var::Cmd),\n}\n\nimpl Cmd {\n    pub async fn run(self, settings: &Settings, store: SqliteStore) -> Result<()> {\n        match self {\n            Self::Alias(cmd) => cmd.run(settings, store).await,\n            Self::Var(cmd) => cmd.run(settings, store).await,\n        }\n    }\n}\n"
  },
  {
    "path": "crates/atuin/src/command/client/history.rs",
    "content": "use std::{\n    fmt::{self, Display},\n    io::{self, IsTerminal, Write},\n    path::PathBuf,\n    time::Duration,\n};\n\nuse atuin_common::utils::{self, Escapable as _};\nuse clap::Subcommand;\nuse eyre::{Context, Result};\nuse runtime_format::{FormatKey, FormatKeyError, ParseSegment, ParsedFmt};\n\n#[cfg(feature = \"daemon\")]\nuse atuin_daemon::emit_event;\n\nuse atuin_client::{\n    database::{Database, Sqlite, current_context},\n    encryption,\n    history::{History, store::HistoryStore},\n    record::sqlite_store::SqliteStore,\n    settings::{\n        FilterMode::{Directory, Global, Session},\n        Settings, Timezone,\n    },\n};\n\n#[cfg(feature = \"sync\")]\nuse atuin_client::{record, sync};\n\nuse log::{debug, warn};\nuse time::{OffsetDateTime, macros::format_description};\n\n#[cfg(feature = \"daemon\")]\nuse super::daemon;\nuse super::search::format_duration_into;\n\n#[derive(Subcommand, Debug)]\n#[command(infer_subcommands = true)]\npub enum Cmd {\n    /// Begins a new command in the history\n    Start {\n        /// Collects the command from the `ATUIN_COMMAND_LINE` environment variable,\n        /// which does not need escaping and is more compatible between OS and shells\n        #[arg(long = \"command-from-env\", hide = true)]\n        cmd_env: bool,\n\n        /// Author of this command, eg `ellie`, `claude`, or `copilot`\n        #[arg(long)]\n        author: Option<String>,\n\n        /// Optional intent/rationale for running this command\n        #[arg(long)]\n        intent: Option<String>,\n\n        command: Vec<String>,\n    },\n\n    /// Finishes a new command in the history (adds time, exit code)\n    End {\n        id: String,\n        #[arg(long, short)]\n        exit: i64,\n        #[arg(long, short)]\n        duration: Option<u64>,\n    },\n\n    /// List all items in history\n    List {\n        #[arg(long, short)]\n        cwd: bool,\n\n        #[arg(long, short)]\n        session: bool,\n\n        #[arg(long)]\n        human: bool,\n\n        /// Show only the text of the command\n        #[arg(long)]\n        cmd_only: bool,\n\n        /// Terminate the output with a null, for better multiline support\n        #[arg(long)]\n        print0: bool,\n\n        #[arg(long, short, default_value = \"true\")]\n        // accept no value\n        #[arg(num_args(0..=1), default_missing_value(\"true\"))]\n        // accept a value\n        #[arg(action = clap::ArgAction::Set)]\n        reverse: bool,\n\n        /// Display the command time in another timezone other than the configured default.\n        ///\n        /// This option takes one of the following kinds of values:\n        /// - the special value \"local\" (or \"l\") which refers to the system time zone\n        /// - an offset from UTC (e.g. \"+9\", \"-2:30\")\n        #[arg(long, visible_alias = \"tz\")]\n        timezone: Option<Timezone>,\n\n        /// Available variables: {command}, {directory}, {duration}, {user}, {host}, {author}, {intent}, {exit}, {time}, {session}, and {uuid}\n        /// Example: --format \"{time} - [{duration}] - {directory}$\\t{command}\"\n        #[arg(long, short)]\n        format: Option<String>,\n    },\n\n    /// Get the last command ran\n    Last {\n        #[arg(long)]\n        human: bool,\n\n        /// Show only the text of the command\n        #[arg(long)]\n        cmd_only: bool,\n\n        /// Display the command time in another timezone other than the configured default.\n        ///\n        /// This option takes one of the following kinds of values:\n        /// - the special value \"local\" (or \"l\") which refers to the system time zone\n        /// - an offset from UTC (e.g. \"+9\", \"-2:30\")\n        #[arg(long, visible_alias = \"tz\")]\n        timezone: Option<Timezone>,\n\n        /// Available variables: {command}, {directory}, {duration}, {user}, {host}, {author}, {intent}, {time}, {session}, {uuid} and {relativetime}.\n        /// Example: --format \"{time} - [{duration}] - {directory}$\\t{command}\"\n        #[arg(long, short)]\n        format: Option<String>,\n    },\n\n    InitStore,\n\n    /// Delete history entries matching the configured exclusion filters\n    Prune {\n        /// List matching history lines without performing the actual deletion.\n        #[arg(short = 'n', long)]\n        dry_run: bool,\n    },\n\n    /// Delete duplicate history entries (that have the same command, cwd and hostname)\n    Dedup {\n        /// List matching history lines without performing the actual deletion.\n        #[arg(short = 'n', long)]\n        dry_run: bool,\n\n        /// Only delete results added before this date\n        #[arg(long, short)]\n        before: String,\n\n        /// How many recent duplicates to keep\n        #[arg(long)]\n        dupkeep: u32,\n    },\n}\n\n#[derive(Clone, Copy, Debug)]\npub enum ListMode {\n    Human,\n    CmdOnly,\n    Regular,\n}\n\nimpl ListMode {\n    pub const fn from_flags(human: bool, cmd_only: bool) -> Self {\n        if human {\n            ListMode::Human\n        } else if cmd_only {\n            ListMode::CmdOnly\n        } else {\n            ListMode::Regular\n        }\n    }\n}\n\n#[allow(clippy::cast_sign_loss)]\npub fn print_list(\n    h: &[History],\n    list_mode: ListMode,\n    format: Option<&str>,\n    print0: bool,\n    reverse: bool,\n    tz: Timezone,\n) {\n    let w = std::io::stdout();\n    let mut w = w.lock();\n\n    let fmt_str = match list_mode {\n        ListMode::Human => format\n            .unwrap_or(\"{time} · {duration}\\t{command}\")\n            .replace(\"\\\\t\", \"\\t\"),\n        ListMode::Regular => format\n            .unwrap_or(\"{time}\\t{command}\\t{duration}\")\n            .replace(\"\\\\t\", \"\\t\"),\n        // not used\n        ListMode::CmdOnly => String::new(),\n    };\n\n    let parsed_fmt = match list_mode {\n        ListMode::Human | ListMode::Regular => parse_fmt(&fmt_str),\n        ListMode::CmdOnly => std::iter::once(ParseSegment::Key(\"command\")).collect(),\n    };\n\n    let iterator = if reverse {\n        Box::new(h.iter().rev()) as Box<dyn Iterator<Item = &History>>\n    } else {\n        Box::new(h.iter()) as Box<dyn Iterator<Item = &History>>\n    };\n\n    let entry_terminator = if print0 { \"\\0\" } else { \"\\n\" };\n    let flush_each_line = print0;\n\n    for history in iterator {\n        let fh = FmtHistory {\n            history,\n            cmd_format: CmdFormat::for_output(&w),\n            tz: &tz,\n        };\n        let args = parsed_fmt.with_args(&fh);\n\n        // Check for formatting errors before attempting to write\n        if let Err(err) = args.status() {\n            eprintln!(\"ERROR: history output failed with: {err}\");\n            std::process::exit(1);\n        }\n\n        let write_result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {\n            write!(w, \"{args}{entry_terminator}\")\n        }));\n\n        match write_result {\n            Ok(Ok(())) => {\n                // Write succeeded\n            }\n            Ok(Err(err)) => {\n                if err.kind() != io::ErrorKind::BrokenPipe {\n                    eprintln!(\"ERROR: Failed to write history output: {err}\");\n                    std::process::exit(1);\n                }\n            }\n            Err(_) => {\n                eprintln!(\"ERROR: Format string caused a formatting error.\");\n                eprintln!(\n                    \"This may be due to an unsupported format string containing special characters.\"\n                );\n                eprintln!(\n                    \"Please check your format string syntax and ensure literal braces are properly escaped.\"\n                );\n                std::process::exit(1);\n            }\n        }\n        if flush_each_line {\n            check_for_write_errors(w.flush());\n        }\n    }\n\n    if !flush_each_line {\n        check_for_write_errors(w.flush());\n    }\n}\n\nfn check_for_write_errors(write: Result<(), io::Error>) {\n    if let Err(err) = write {\n        // Ignore broken pipe (issue #626)\n        if err.kind() != io::ErrorKind::BrokenPipe {\n            eprintln!(\"ERROR: History output failed with the following error: {err}\");\n            std::process::exit(1);\n        }\n    }\n}\n\n/// Type wrapper around `History` with formatting settings.\n#[derive(Clone, Copy, Debug)]\nstruct FmtHistory<'a> {\n    history: &'a History,\n    cmd_format: CmdFormat,\n    tz: &'a Timezone,\n}\n\n#[derive(Clone, Copy, Debug)]\nenum CmdFormat {\n    Literal,\n    Escaped,\n}\nimpl CmdFormat {\n    fn for_output<O: IsTerminal>(out: &O) -> Self {\n        if out.is_terminal() {\n            Self::Escaped\n        } else {\n            Self::Literal\n        }\n    }\n}\n\nstatic TIME_FMT: &[time::format_description::FormatItem<'static>] =\n    format_description!(\"[year]-[month]-[day] [hour repr:24]:[minute]:[second]\");\n\n/// defines how to format the history\nimpl FormatKey for FmtHistory<'_> {\n    #[allow(clippy::cast_sign_loss)]\n    fn fmt(&self, key: &str, f: &mut fmt::Formatter<'_>) -> Result<(), FormatKeyError> {\n        match key {\n            \"command\" => match self.cmd_format {\n                CmdFormat::Literal => f.write_str(self.history.command.trim()),\n                CmdFormat::Escaped => f.write_str(&self.history.command.trim().escape_control()),\n            }?,\n            \"directory\" => f.write_str(self.history.cwd.trim())?,\n            \"exit\" => f.write_str(&self.history.exit.to_string())?,\n            \"duration\" => {\n                let dur = Duration::from_nanos(std::cmp::max(self.history.duration, 0) as u64);\n                format_duration_into(dur, f)?;\n            }\n            \"time\" => {\n                self.history\n                    .timestamp\n                    .to_offset(self.tz.0)\n                    .format(TIME_FMT)\n                    .map_err(|_| fmt::Error)?\n                    .fmt(f)?;\n            }\n            \"relativetime\" => {\n                let since = OffsetDateTime::now_utc() - self.history.timestamp;\n                let d = Duration::try_from(since).unwrap_or_default();\n                format_duration_into(d, f)?;\n            }\n            \"host\" => f.write_str(\n                self.history\n                    .hostname\n                    .split_once(':')\n                    .map_or(&self.history.hostname, |(host, _)| host),\n            )?,\n            \"author\" => f.write_str(&self.history.author)?,\n            \"intent\" => f.write_str(self.history.intent.as_deref().unwrap_or_default())?,\n            \"user\" => f.write_str(\n                self.history\n                    .hostname\n                    .split_once(':')\n                    .map_or(\"\", |(_, user)| user),\n            )?,\n            \"session\" => f.write_str(&self.history.session)?,\n            \"uuid\" => f.write_str(&self.history.id.0)?,\n            _ => return Err(FormatKeyError::UnknownKey),\n        }\n        Ok(())\n    }\n}\n\nfn parse_fmt(format: &str) -> ParsedFmt<'_> {\n    match ParsedFmt::new(format) {\n        Ok(fmt) => fmt,\n        Err(err) => {\n            eprintln!(\"ERROR: History formatting failed with the following error: {err}\");\n\n            if format.contains('\"') && (format.contains(\":{\") || format.contains(\",{\")) {\n                eprintln!(\"It looks like you're trying to create JSON output.\");\n                eprintln!(\"For JSON, you need to escape literal braces by doubling them:\");\n                eprintln!(\"Example: '{{\\\"command\\\":\\\"{{command}}\\\",\\\"time\\\":\\\"{{time}}\\\"}}'\");\n            } else {\n                eprintln!(\n                    \"If your formatting string contains literal curly braces, you need to escape them by doubling:\"\n                );\n                eprintln!(\"Use {{{{ for literal {{ and }}}} for literal }}\");\n            }\n            std::process::exit(1)\n        }\n    }\n}\n\nimpl Cmd {\n    fn apply_start_metadata(history: &mut History, author: Option<&str>, intent: Option<&str>) {\n        if let Some(author) = author.map(str::trim).filter(|author| !author.is_empty()) {\n            author.clone_into(&mut history.author);\n        }\n\n        if let Some(intent) = intent.map(str::trim).filter(|intent| !intent.is_empty()) {\n            history.intent = Some(intent.to_owned());\n        } else if intent.is_some() {\n            history.intent = None;\n        }\n    }\n\n    #[allow(clippy::too_many_lines, clippy::cast_possible_truncation)]\n    async fn handle_start(\n        db: &impl Database,\n        settings: &Settings,\n        command: &str,\n        author: Option<&str>,\n        intent: Option<&str>,\n    ) -> Result<()> {\n        // It's better for atuin to silently fail here and attempt to\n        // store whatever is ran, than to throw an error to the terminal\n        let cwd = utils::get_current_dir();\n\n        let mut h: History = History::capture()\n            .timestamp(OffsetDateTime::now_utc())\n            .command(command)\n            .cwd(cwd)\n            .build()\n            .into();\n        Self::apply_start_metadata(&mut h, author, intent);\n\n        if !h.should_save(settings) {\n            return Ok(());\n        }\n\n        // print the ID\n        // we use this as the key for calling end\n        println!(\"{}\", h.id);\n\n        // Silently ignore database errors to avoid breaking the shell\n        // This is important when disk is full or database is locked\n        if let Err(e) = db.save(&h).await {\n            debug!(\"failed to save history: {e}\");\n        }\n\n        Ok(())\n    }\n\n    #[cfg(feature = \"daemon\")]\n    async fn handle_daemon_start(\n        settings: &Settings,\n        command: &str,\n        author: Option<&str>,\n        intent: Option<&str>,\n    ) -> Result<()> {\n        // It's better for atuin to silently fail here and attempt to\n        // store whatever is ran, than to throw an error to the terminal\n        let cwd = utils::get_current_dir();\n\n        let mut h: History = History::capture()\n            .timestamp(OffsetDateTime::now_utc())\n            .command(command)\n            .cwd(cwd)\n            .build()\n            .into();\n        Self::apply_start_metadata(&mut h, author, intent);\n\n        if !h.should_save(settings) {\n            return Ok(());\n        }\n\n        // Attempt to start history via daemon, but silently ignore errors\n        // to avoid breaking the shell when the daemon is unavailable or disk is full\n        let resp = match daemon::start_history(settings, h.clone()).await {\n            Ok(id) => id,\n            Err(e) => {\n                debug!(\"failed to start history via daemon: {e}\");\n                h.id.0.clone()\n            }\n        };\n\n        // print the ID\n        // we use this as the key for calling end\n        println!(\"{resp}\");\n\n        Ok(())\n    }\n\n    #[allow(unused_variables)]\n    async fn handle_end(\n        db: &impl Database,\n        store: SqliteStore,\n        history_store: HistoryStore,\n        settings: &Settings,\n        id: &str,\n        exit: i64,\n        duration: Option<u64>,\n    ) -> Result<()> {\n        if id.trim() == \"\" {\n            return Ok(());\n        }\n\n        let Some(mut h) = db.load(id).await? else {\n            warn!(\"history entry is missing\");\n            return Ok(());\n        };\n\n        if h.duration > 0 {\n            debug!(\"cannot end history - already has duration\");\n\n            // returning OK as this can occur if someone Ctrl-c a prompt\n            return Ok(());\n        }\n\n        if !settings.store_failed && exit > 0 {\n            debug!(\"history has non-zero exit code, and store_failed is false\");\n\n            // the history has already been inserted half complete. remove it\n            db.delete(h).await?;\n\n            return Ok(());\n        }\n\n        h.exit = exit;\n        h.duration = match duration {\n            Some(value) => i64::try_from(value).context(\"command took over 292 years\")?,\n            None => i64::try_from((OffsetDateTime::now_utc() - h.timestamp).whole_nanoseconds())\n                .context(\"command took over 292 years\")?,\n        };\n\n        db.update(&h).await?;\n        history_store.push(h).await?;\n\n        if settings.should_sync().await? {\n            #[cfg(feature = \"sync\")]\n            {\n                if settings.sync.records {\n                    let (_, downloaded) = record::sync::sync(settings, &store).await?;\n                    Settings::save_sync_time().await?;\n\n                    crate::sync::build(settings, &store, db, Some(&downloaded)).await?;\n                } else {\n                    debug!(\"running periodic background sync\");\n                    sync::sync(settings, false, db).await?;\n                }\n            }\n            #[cfg(not(feature = \"sync\"))]\n            debug!(\"not compiled with sync support\");\n        } else {\n            debug!(\"sync disabled! not syncing\");\n        }\n\n        Ok(())\n    }\n\n    #[cfg(feature = \"daemon\")]\n    async fn handle_daemon_end(\n        settings: &Settings,\n        id: &str,\n        exit: i64,\n        duration: Option<u64>,\n    ) -> Result<()> {\n        daemon::end_history(settings, id.to_string(), duration.unwrap_or(0), exit).await?;\n\n        Ok(())\n    }\n\n    #[allow(clippy::too_many_arguments)]\n    #[allow(clippy::fn_params_excessive_bools)]\n    async fn handle_list(\n        db: &impl Database,\n        settings: &Settings,\n        context: atuin_client::database::Context,\n        session: bool,\n        cwd: bool,\n        mode: ListMode,\n        format: Option<String>,\n        include_deleted: bool,\n        print0: bool,\n        reverse: bool,\n        tz: Timezone,\n    ) -> Result<()> {\n        let filters = match (session, cwd) {\n            (true, true) => [Session, Directory],\n            (true, false) => [Session, Global],\n            (false, true) => [Global, Directory],\n            (false, false) => [\n                settings.default_filter_mode(context.git_root.is_some()),\n                Global,\n            ],\n        };\n\n        let history = db\n            .list(&filters, &context, None, false, include_deleted)\n            .await?;\n\n        print_list(\n            &history,\n            mode,\n            match format {\n                None => Some(settings.history_format.as_str()),\n                _ => format.as_deref(),\n            },\n            print0,\n            reverse,\n            tz,\n        );\n\n        Ok(())\n    }\n\n    async fn handle_prune(\n        db: &impl Database,\n        settings: &Settings,\n        store: SqliteStore,\n        context: atuin_client::database::Context,\n        dry_run: bool,\n    ) -> Result<()> {\n        // Grab all executed commands and filter them using History::should_save.\n        // We could iterate or paginate here if memory usage becomes an issue.\n        let matches: Vec<History> = db\n            .list(&[Global], &context, None, false, false)\n            .await?\n            .into_iter()\n            .filter(|h| !h.should_save(settings))\n            .collect();\n\n        match matches.len() {\n            0 => {\n                println!(\"No entries to prune.\");\n                return Ok(());\n            }\n            1 => println!(\"Found 1 entry to prune.\"),\n            n => println!(\"Found {n} entries to prune.\"),\n        }\n\n        if dry_run {\n            print_list(\n                &matches,\n                ListMode::Human,\n                Some(settings.history_format.as_str()),\n                false,\n                false,\n                settings.timezone,\n            );\n        } else {\n            let encryption_key: [u8; 32] = encryption::load_key(settings)\n                .context(\"could not load encryption key\")?\n                .into();\n            let host_id = Settings::host_id().await?;\n            let history_store = HistoryStore::new(store.clone(), host_id, encryption_key);\n\n            for entry in matches {\n                eprintln!(\"deleting {}\", entry.id);\n                if settings.sync.records {\n                    let (id, _) = history_store.delete(entry.id.clone()).await?;\n                    history_store.incremental_build(db, &[id]).await?;\n                } else {\n                    db.delete(entry.clone()).await?;\n                }\n            }\n\n            #[cfg(feature = \"daemon\")]\n            let _ = emit_event(atuin_daemon::DaemonEvent::HistoryPruned).await;\n        }\n        Ok(())\n    }\n\n    async fn handle_dedup(\n        db: &impl Database,\n        settings: &Settings,\n        store: SqliteStore,\n        before: i64,\n        dupkeep: u32,\n        dry_run: bool,\n    ) -> Result<()> {\n        if dupkeep == 0 {\n            eprintln!(\n                \"\\\"--dupkeep 0\\\" would keep 0 copies of duplicate commands and thus delete all of them! Use \\\"atuin search --delete ...\\\" if you really want that.\"\n            );\n            std::process::exit(1);\n        }\n\n        let matches: Vec<History> = db.get_dups(before, dupkeep).await?;\n\n        match matches.len() {\n            0 => {\n                println!(\"No duplicates to delete.\");\n                return Ok(());\n            }\n            1 => println!(\"Found 1 duplicate to delete.\"),\n            n => println!(\"Found {n} duplicates to delete.\"),\n        }\n\n        if dry_run {\n            print_list(\n                &matches,\n                ListMode::Human,\n                Some(settings.history_format.as_str()),\n                false,\n                false,\n                settings.timezone,\n            );\n        } else {\n            let encryption_key: [u8; 32] = encryption::load_key(settings)\n                .context(\"could not load encryption key\")?\n                .into();\n            let host_id = Settings::host_id().await?;\n            let history_store = HistoryStore::new(store.clone(), host_id, encryption_key);\n\n            #[cfg(feature = \"daemon\")]\n            let ids = matches.iter().map(|h| h.id.clone()).collect::<Vec<_>>();\n\n            for entry in matches {\n                eprintln!(\"deleting {}\", entry.id);\n                if settings.sync.records {\n                    let (id, _) = history_store.delete(entry.id).await?;\n                    history_store.incremental_build(db, &[id]).await?;\n                } else {\n                    db.delete(entry).await?;\n                }\n            }\n\n            #[cfg(feature = \"daemon\")]\n            let _ = emit_event(atuin_daemon::DaemonEvent::HistoryDeleted { ids }).await;\n        }\n        Ok(())\n    }\n\n    pub async fn run(self, settings: &Settings) -> Result<()> {\n        let context = current_context().await?;\n\n        #[cfg(feature = \"daemon\")]\n        // Skip initializing any databases for start/end, if the daemon is enabled\n        if settings.daemon.enabled {\n            match self {\n                Self::Start { .. } => {\n                    let command = self.get_start_command().unwrap_or_default();\n                    let (author, intent) = self.get_start_metadata().unwrap_or_default();\n                    return Self::handle_daemon_start(settings, &command, author, intent).await;\n                }\n\n                Self::End { id, exit, duration } => {\n                    return Self::handle_daemon_end(settings, &id, exit, duration).await;\n                }\n\n                _ => {}\n            }\n        }\n\n        let db_path = PathBuf::from(settings.db_path.as_str());\n        let record_store_path = PathBuf::from(settings.record_store_path.as_str());\n\n        let db = Sqlite::new(db_path, settings.local_timeout).await?;\n        let store = SqliteStore::new(record_store_path, settings.local_timeout).await?;\n\n        let encryption_key: [u8; 32] = encryption::load_key(settings)\n            .context(\"could not load encryption key\")?\n            .into();\n\n        let host_id = Settings::host_id().await?;\n        let history_store = HistoryStore::new(store.clone(), host_id, encryption_key);\n\n        match self {\n            Self::Start { .. } => {\n                let command = self.get_start_command().unwrap_or_default();\n                let (author, intent) = self.get_start_metadata().unwrap_or_default();\n                Self::handle_start(&db, settings, &command, author, intent).await\n            }\n            Self::End { id, exit, duration } => {\n                Self::handle_end(&db, store, history_store, settings, &id, exit, duration).await\n            }\n            Self::List {\n                session,\n                cwd,\n                human,\n                cmd_only,\n                print0,\n                reverse,\n                timezone,\n                format,\n            } => {\n                let mode = ListMode::from_flags(human, cmd_only);\n                let tz = timezone.unwrap_or(settings.timezone);\n                Self::handle_list(\n                    &db, settings, context, session, cwd, mode, format, false, print0, reverse, tz,\n                )\n                .await\n            }\n\n            Self::Last {\n                human,\n                cmd_only,\n                timezone,\n                format,\n            } => {\n                let last = db.last().await?;\n                let last = last.as_slice();\n                let tz = timezone.unwrap_or(settings.timezone);\n                print_list(\n                    last,\n                    ListMode::from_flags(human, cmd_only),\n                    match format {\n                        None => Some(settings.history_format.as_str()),\n                        _ => format.as_deref(),\n                    },\n                    false,\n                    true,\n                    tz,\n                );\n\n                Ok(())\n            }\n\n            Self::InitStore => history_store.init_store(&db).await,\n\n            Self::Prune { dry_run } => {\n                Self::handle_prune(&db, settings, store, context, dry_run).await\n            }\n\n            Self::Dedup {\n                dry_run,\n                before,\n                dupkeep,\n            } => {\n                let before = i64::try_from(\n                    interim::parse_date_string(\n                        before.as_str(),\n                        OffsetDateTime::now_utc(),\n                        interim::Dialect::Uk,\n                    )?\n                    .unix_timestamp_nanos(),\n                )?;\n                Self::handle_dedup(&db, settings, store, before, dupkeep, dry_run).await\n            }\n        }\n    }\n\n    /// Returns the command line to use for the `Start` variant.\n    /// Returns `None` for any other variant.\n    fn get_start_command(&self) -> Option<String> {\n        match self {\n            Self::Start { cmd_env: true, .. } => {\n                Some(std::env::var(\"ATUIN_COMMAND_LINE\").unwrap_or_default())\n            }\n            Self::Start { command, .. } => Some(command.join(\" \")),\n            _ => None,\n        }\n    }\n\n    /// Returns `(author, intent)` for the `Start` variant.\n    /// Returns `None` for any other variant.\n    fn get_start_metadata(&self) -> Option<(Option<&str>, Option<&str>)> {\n        match self {\n            Self::Start { author, intent, .. } => Some((author.as_deref(), intent.as_deref())),\n            _ => None,\n        }\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn test_format_string_no_panic() {\n        // Don't panic but provide helpful output (issue #2776)\n        let malformed_json = r#\"{\"command\":\"{command}\",\"key\":\"value\"}\"#;\n\n        let result = std::panic::catch_unwind(|| parse_fmt(malformed_json));\n\n        assert!(result.is_ok());\n    }\n\n    #[test]\n    fn test_valid_formats_still_work() {\n        assert!(std::panic::catch_unwind(|| parse_fmt(\"{command}\")).is_ok());\n        assert!(std::panic::catch_unwind(|| parse_fmt(\"{time} - {command}\")).is_ok());\n    }\n}\n"
  },
  {
    "path": "crates/atuin/src/command/client/import.rs",
    "content": "use std::env;\n\nuse async_trait::async_trait;\nuse clap::Parser;\nuse eyre::Result;\nuse indicatif::ProgressBar;\n\nuse atuin_client::{\n    database::Database,\n    history::History,\n    import::{\n        Importer, Loader, bash::Bash, fish::Fish, nu::Nu, nu_histdb::NuHistDb,\n        powershell::PowerShell, replxx::Replxx, resh::Resh, xonsh::Xonsh,\n        xonsh_sqlite::XonshSqlite, zsh::Zsh, zsh_histdb::ZshHistDb,\n    },\n};\n\n#[derive(Parser, Debug)]\n#[command(infer_subcommands = true)]\npub enum Cmd {\n    /// Import history for the current shell\n    Auto,\n\n    /// Import history from the zsh history file\n    Zsh,\n    /// Import history from the zsh history file\n    ZshHistDb,\n    /// Import history from the bash history file\n    Bash,\n    /// Import history from the replxx history file\n    Replxx,\n    /// Import history from the resh history file\n    Resh,\n    /// Import history from the fish history file\n    Fish,\n    /// Import history from the nu history file\n    Nu,\n    /// Import history from the nu history file\n    NuHistDb,\n    /// Import history from xonsh json files\n    Xonsh,\n    /// Import history from xonsh sqlite db\n    XonshSqlite,\n    /// Import history from the powershell history file\n    Powershell,\n}\n\nconst BATCH_SIZE: usize = 100;\n\nimpl Cmd {\n    #[allow(clippy::cognitive_complexity)]\n    pub async fn run<DB: Database>(&self, db: &DB) -> Result<()> {\n        println!(\"        Atuin         \");\n        println!(\"======================\");\n        println!(\"          \\u{1f30d}          \");\n        println!(\"       \\u{1f418}\\u{1f418}\\u{1f418}\\u{1f418}       \");\n        println!(\"          \\u{1f422}          \");\n        println!(\"======================\");\n        println!(\"Importing history...\");\n\n        match self {\n            Self::Auto => {\n                if cfg!(windows) {\n                    return if env::var(\"PSModulePath\").is_ok() {\n                        println!(\"Detected PowerShell\");\n                        import::<PowerShell, DB>(db).await\n                    } else {\n                        println!(\"Could not detect the current shell.\");\n                        println!(\"Please run atuin import <SHELL>.\");\n                        println!(\"To view a list of shells, run atuin import.\");\n                        Ok(())\n                    };\n                }\n\n                // $XONSH_HISTORY_BACKEND isn't always set, but $XONSH_HISTORY_FILE is\n                let xonsh_histfile =\n                    env::var(\"XONSH_HISTORY_FILE\").unwrap_or_else(|_| String::new());\n                let shell = env::var(\"SHELL\").unwrap_or_else(|_| String::from(\"NO_SHELL\"));\n\n                if xonsh_histfile.to_lowercase().ends_with(\".json\") {\n                    println!(\"Detected Xonsh\",);\n                    import::<Xonsh, DB>(db).await\n                } else if xonsh_histfile.to_lowercase().ends_with(\".sqlite\") {\n                    println!(\"Detected Xonsh (SQLite backend)\");\n                    import::<XonshSqlite, DB>(db).await\n                } else if shell.ends_with(\"/zsh\") {\n                    if ZshHistDb::histpath().is_ok() {\n                        println!(\n                            \"Detected Zsh-HistDb, using :{}\",\n                            ZshHistDb::histpath().unwrap().to_str().unwrap()\n                        );\n                        import::<ZshHistDb, DB>(db).await\n                    } else {\n                        println!(\"Detected ZSH\");\n                        import::<Zsh, DB>(db).await\n                    }\n                } else if shell.ends_with(\"/fish\") {\n                    println!(\"Detected Fish\");\n                    import::<Fish, DB>(db).await\n                } else if shell.ends_with(\"/bash\") {\n                    println!(\"Detected Bash\");\n                    import::<Bash, DB>(db).await\n                } else if shell.ends_with(\"/nu\") {\n                    if NuHistDb::histpath().is_ok() {\n                        println!(\n                            \"Detected Nu-HistDb, using :{}\",\n                            NuHistDb::histpath().unwrap().to_str().unwrap()\n                        );\n                        import::<NuHistDb, DB>(db).await\n                    } else {\n                        println!(\"Detected Nushell\");\n                        import::<Nu, DB>(db).await\n                    }\n                } else if shell.ends_with(\"/pwsh\") {\n                    println!(\"Detected PowerShell\");\n                    import::<PowerShell, DB>(db).await\n                } else {\n                    println!(\"cannot import {shell} history\");\n                    Ok(())\n                }\n            }\n\n            Self::Zsh => import::<Zsh, DB>(db).await,\n            Self::ZshHistDb => import::<ZshHistDb, DB>(db).await,\n            Self::Bash => import::<Bash, DB>(db).await,\n            Self::Replxx => import::<Replxx, DB>(db).await,\n            Self::Resh => import::<Resh, DB>(db).await,\n            Self::Fish => import::<Fish, DB>(db).await,\n            Self::Nu => import::<Nu, DB>(db).await,\n            Self::NuHistDb => import::<NuHistDb, DB>(db).await,\n            Self::Xonsh => import::<Xonsh, DB>(db).await,\n            Self::XonshSqlite => import::<XonshSqlite, DB>(db).await,\n            Self::Powershell => import::<PowerShell, DB>(db).await,\n        }\n    }\n}\n\npub struct HistoryImporter<'db, DB: Database> {\n    pb: ProgressBar,\n    buf: Vec<History>,\n    db: &'db DB,\n}\n\nimpl<'db, DB: Database> HistoryImporter<'db, DB> {\n    fn new(db: &'db DB, len: usize) -> Self {\n        Self {\n            pb: ProgressBar::new(len as u64),\n            buf: Vec::with_capacity(BATCH_SIZE),\n            db,\n        }\n    }\n\n    async fn flush(self) -> Result<()> {\n        if !self.buf.is_empty() {\n            self.db.save_bulk(&self.buf).await?;\n        }\n        self.pb.finish();\n        Ok(())\n    }\n}\n\n#[async_trait]\nimpl<DB: Database> Loader for HistoryImporter<'_, DB> {\n    async fn push(&mut self, hist: History) -> Result<()> {\n        self.pb.inc(1);\n        self.buf.push(hist);\n        if self.buf.len() == self.buf.capacity() {\n            self.db.save_bulk(&self.buf).await?;\n            self.buf.clear();\n        }\n        Ok(())\n    }\n}\n\nasync fn import<I: Importer + Send, DB: Database>(db: &DB) -> Result<()> {\n    println!(\"Importing history from {}\", I::NAME);\n\n    let mut importer = I::new().await?;\n    let len = importer.entries().await.unwrap();\n    let mut loader = HistoryImporter::new(db, len);\n    importer.load(&mut loader).await?;\n    loader.flush().await?;\n\n    println!(\"Import complete!\");\n    Ok(())\n}\n"
  },
  {
    "path": "crates/atuin/src/command/client/info.rs",
    "content": "use atuin_client::settings::Settings;\r\n\r\nuse crate::{SHA, VERSION};\r\n\r\npub fn run(settings: &Settings) {\r\n    let config = atuin_common::utils::config_dir();\r\n    let mut config_file = config.clone();\r\n    config_file.push(\"config.toml\");\r\n    let mut sever_config = config;\r\n    sever_config.push(\"server.toml\");\r\n\r\n    let config_paths = format!(\r\n        \"Config files:\\nclient config: {:?}\\nserver config: {:?}\\nclient db path: {:?}\\nkey path: {:?}\\nmeta db path: {:?}\",\r\n        config_file.to_string_lossy(),\r\n        sever_config.to_string_lossy(),\r\n        settings.db_path,\r\n        settings.key_path,\r\n        settings.meta.db_path\r\n    );\r\n\r\n    let env_vars = format!(\r\n        \"Env Vars:\\nATUIN_CONFIG_DIR = {:?}\",\r\n        std::env::var(\"ATUIN_CONFIG_DIR\").unwrap_or_else(|_| \"None\".into())\r\n    );\r\n\r\n    let general_info = format!(\"Version info:\\nversion: {VERSION}\\ncommit:  {SHA}\");\r\n\r\n    let print_out = format!(\"{config_paths}\\n\\n{env_vars}\\n\\n{general_info}\");\r\n\r\n    println!(\"{print_out}\");\r\n}\r\n"
  },
  {
    "path": "crates/atuin/src/command/client/init/bash.rs",
    "content": "use atuin_client::settings::Tmux;\nuse atuin_dotfiles::store::{AliasStore, var::VarStore};\nuse eyre::Result;\n\nfn print_tmux_config(tmux: &Tmux) {\n    if tmux.enabled {\n        println!(\"export ATUIN_TMUX_POPUP_WIDTH='{}'\", tmux.width);\n        println!(\"export ATUIN_TMUX_POPUP_HEIGHT='{}'\", tmux.height);\n    } else {\n        println!(\"export ATUIN_TMUX_POPUP=false\");\n    }\n}\n\npub fn init_static(disable_up_arrow: bool, disable_ctrl_r: bool, disable_ai: bool, tmux: &Tmux) {\n    let base = include_str!(\"../../../shell/atuin.bash\");\n\n    let (bind_ctrl_r, bind_up_arrow) = if std::env::var(\"ATUIN_NOBIND\").is_ok() {\n        (false, false)\n    } else {\n        (!disable_ctrl_r, !disable_up_arrow)\n    };\n\n    print_tmux_config(tmux);\n    println!(\"__atuin_bind_ctrl_r={bind_ctrl_r}\");\n    println!(\"__atuin_bind_up_arrow={bind_up_arrow}\");\n    println!(\"{base}\");\n\n    #[cfg(feature = \"ai\")]\n    if !disable_ai {\n        let bind_ai = atuin_ai::commands::init::generate_bash_integration();\n        println!(\"{bind_ai}\");\n    }\n}\n\npub async fn init(\n    aliases: AliasStore,\n    vars: VarStore,\n    disable_up_arrow: bool,\n    disable_ctrl_r: bool,\n    disable_ai: bool,\n    tmux: &Tmux,\n) -> Result<()> {\n    init_static(disable_up_arrow, disable_ctrl_r, disable_ai, tmux);\n\n    let aliases = atuin_dotfiles::shell::bash::alias_config(&aliases).await;\n    let vars = atuin_dotfiles::shell::bash::var_config(&vars).await;\n\n    println!(\"{aliases}\");\n    println!(\"{vars}\");\n\n    Ok(())\n}\n"
  },
  {
    "path": "crates/atuin/src/command/client/init/fish.rs",
    "content": "use atuin_client::settings::Tmux;\nuse atuin_dotfiles::store::{AliasStore, var::VarStore};\nuse eyre::Result;\n\nfn print_tmux_config(tmux: &Tmux) {\n    if tmux.enabled {\n        println!(\"set -gx ATUIN_TMUX_POPUP_WIDTH '{}'\", tmux.width);\n        println!(\"set -gx ATUIN_TMUX_POPUP_HEIGHT '{}'\", tmux.height);\n    } else {\n        println!(\"set -gx ATUIN_TMUX_POPUP false\");\n    }\n}\n\nfn print_bindings(\n    indent: &str,\n    disable_up_arrow: bool,\n    disable_ctrl_r: bool,\n    bind_ctrl_r: &str,\n    bind_up_arrow: &str,\n    bind_ctrl_r_ins: &str,\n    bind_up_arrow_ins: &str,\n) {\n    if !disable_ctrl_r {\n        println!(\"{indent}{bind_ctrl_r}\");\n    }\n    if !disable_up_arrow {\n        println!(\"{indent}{bind_up_arrow}\");\n    }\n\n    println!(\"{indent}if bind -M insert >/dev/null 2>&1\");\n    if !disable_ctrl_r {\n        println!(\"{indent}{indent}{bind_ctrl_r_ins}\");\n    }\n    if !disable_up_arrow {\n        println!(\"{indent}{indent}{bind_up_arrow_ins}\");\n    }\n    println!(\"{indent}end\");\n}\n\npub fn init_static(disable_up_arrow: bool, disable_ctrl_r: bool, disable_ai: bool, tmux: &Tmux) {\n    let indent = \" \".repeat(4);\n\n    let base = include_str!(\"../../../shell/atuin.fish\");\n\n    print_tmux_config(tmux);\n    println!(\"{base}\");\n\n    if std::env::var(\"ATUIN_NOBIND\").is_err() {\n        println!(\"if string match -q '4.*' $version\");\n\n        // In fish 4.0 and above the option bind -k doesn't exist anymore,\n        // instead we can use key names and modifiers directly.\n        print_bindings(\n            &indent,\n            disable_up_arrow,\n            disable_ctrl_r,\n            \"bind ctrl-r _atuin_search\",\n            \"bind up _atuin_bind_up\",\n            \"bind -M insert ctrl-r _atuin_search\",\n            \"bind -M insert up _atuin_bind_up\",\n        );\n\n        println!(\"else\");\n\n        // We keep these for compatibility with fish 3.x\n        print_bindings(\n            &indent,\n            disable_up_arrow,\n            disable_ctrl_r,\n            r\"bind \\cr _atuin_search\",\n            &[\n                r\"bind -k up _atuin_bind_up\",\n                r\"bind \\eOA _atuin_bind_up\",\n                r\"bind \\e\\[A _atuin_bind_up\",\n            ]\n            .join(\"; \"),\n            r\"bind -M insert \\cr _atuin_search\",\n            &[\n                r\"bind -M insert -k up _atuin_bind_up\",\n                r\"bind -M insert \\eOA _atuin_bind_up\",\n                r\"bind -M insert \\e\\[A _atuin_bind_up\",\n            ]\n            .join(\"; \"),\n        );\n\n        println!(\"end\");\n\n        #[cfg(feature = \"ai\")]\n        if !disable_ai {\n            let bind_ai = atuin_ai::commands::init::generate_fish_integration();\n            println!(\"{bind_ai}\");\n        }\n    }\n}\n\npub async fn init(\n    aliases: AliasStore,\n    vars: VarStore,\n    disable_up_arrow: bool,\n    disable_ctrl_r: bool,\n    disable_ai: bool,\n    tmux: &Tmux,\n) -> Result<()> {\n    init_static(disable_up_arrow, disable_ctrl_r, disable_ai, tmux);\n\n    let aliases = atuin_dotfiles::shell::fish::alias_config(&aliases).await;\n    let vars = atuin_dotfiles::shell::fish::var_config(&vars).await;\n\n    println!(\"{aliases}\");\n    println!(\"{vars}\");\n\n    Ok(())\n}\n"
  },
  {
    "path": "crates/atuin/src/command/client/init/powershell.rs",
    "content": "use atuin_client::settings::Tmux;\nuse atuin_dotfiles::store::{AliasStore, var::VarStore};\n\npub fn init_static(disable_up_arrow: bool, disable_ctrl_r: bool, _tmux: &Tmux) {\n    let base = include_str!(\"../../../shell/atuin.ps1\");\n\n    let (bind_ctrl_r, bind_up_arrow) = if std::env::var(\"ATUIN_NOBIND\").is_ok() {\n        (false, false)\n    } else {\n        (!disable_ctrl_r, !disable_up_arrow)\n    };\n\n    // TODO: tmux popup for Powershell\n    println!(\"{base}\");\n    println!(\n        \"Enable-AtuinSearchKeys -CtrlR {} -UpArrow {}\",\n        ps_bool(bind_ctrl_r),\n        ps_bool(bind_up_arrow)\n    );\n}\n\npub async fn init(\n    aliases: AliasStore,\n    vars: VarStore,\n    disable_up_arrow: bool,\n    disable_ctrl_r: bool,\n    tmux: &Tmux,\n) -> eyre::Result<()> {\n    init_static(disable_up_arrow, disable_ctrl_r, tmux);\n\n    let aliases = atuin_dotfiles::shell::powershell::alias_config(&aliases).await;\n    let vars = atuin_dotfiles::shell::powershell::var_config(&vars).await;\n\n    println!(\"{aliases}\");\n    println!(\"{vars}\");\n\n    Ok(())\n}\n\nfn ps_bool(value: bool) -> &'static str {\n    if value { \"$true\" } else { \"$false\" }\n}\n"
  },
  {
    "path": "crates/atuin/src/command/client/init/xonsh.rs",
    "content": "use atuin_client::settings::Tmux;\nuse atuin_dotfiles::store::{AliasStore, var::VarStore};\nuse eyre::Result;\n\npub fn init_static(disable_up_arrow: bool, disable_ctrl_r: bool, _tmux: &Tmux) {\n    let base = include_str!(\"../../../shell/atuin.xsh\");\n\n    let (bind_ctrl_r, bind_up_arrow) = if std::env::var(\"ATUIN_NOBIND\").is_ok() {\n        (false, false)\n    } else {\n        (!disable_ctrl_r, !disable_up_arrow)\n    };\n\n    // TODO: tmux popup for xonsh\n    println!(\n        \"_ATUIN_BIND_CTRL_R={}\",\n        if bind_ctrl_r { \"True\" } else { \"False\" }\n    );\n    println!(\n        \"_ATUIN_BIND_UP_ARROW={}\",\n        if bind_up_arrow { \"True\" } else { \"False\" }\n    );\n    println!(\"{base}\");\n}\n\npub async fn init(\n    aliases: AliasStore,\n    vars: VarStore,\n    disable_up_arrow: bool,\n    disable_ctrl_r: bool,\n    tmux: &Tmux,\n) -> Result<()> {\n    init_static(disable_up_arrow, disable_ctrl_r, tmux);\n\n    let aliases = atuin_dotfiles::shell::xonsh::alias_config(&aliases).await;\n    let vars = atuin_dotfiles::shell::xonsh::var_config(&vars).await;\n\n    println!(\"{aliases}\");\n    println!(\"{vars}\");\n\n    Ok(())\n}\n"
  },
  {
    "path": "crates/atuin/src/command/client/init/zsh.rs",
    "content": "use atuin_client::settings::Tmux;\nuse atuin_dotfiles::store::{AliasStore, var::VarStore};\nuse eyre::Result;\n\nfn print_tmux_config(tmux: &Tmux) {\n    if tmux.enabled {\n        println!(\"export ATUIN_TMUX_POPUP_WIDTH='{}'\", tmux.width);\n        println!(\"export ATUIN_TMUX_POPUP_HEIGHT='{}'\", tmux.height);\n    } else {\n        println!(\"export ATUIN_TMUX_POPUP=false\");\n    }\n}\n\npub fn init_static(disable_up_arrow: bool, disable_ctrl_r: bool, disable_ai: bool, tmux: &Tmux) {\n    let base = include_str!(\"../../../shell/atuin.zsh\");\n\n    print_tmux_config(tmux);\n    println!(\"{base}\");\n\n    if std::env::var(\"ATUIN_NOBIND\").is_err() {\n        const BIND_CTRL_R: &str = r\"bindkey -M emacs '^r' atuin-search\nbindkey -M viins '^r' atuin-search-viins\nbindkey -M vicmd '/' atuin-search\";\n\n        const BIND_UP_ARROW: &str = r\"bindkey -M emacs '^[[A' atuin-up-search\nbindkey -M vicmd '^[[A' atuin-up-search-vicmd\nbindkey -M viins '^[[A' atuin-up-search-viins\nbindkey -M emacs '^[OA' atuin-up-search\nbindkey -M vicmd '^[OA' atuin-up-search-vicmd\nbindkey -M viins '^[OA' atuin-up-search-viins\nbindkey -M vicmd 'k' atuin-up-search-vicmd\";\n\n        if !disable_ctrl_r {\n            println!(\"{BIND_CTRL_R}\");\n        }\n        if !disable_up_arrow {\n            println!(\"{BIND_UP_ARROW}\");\n        }\n\n        #[cfg(feature = \"ai\")]\n        if !disable_ai {\n            let bind_ai = atuin_ai::commands::init::generate_zsh_integration();\n\n            println!(\"{bind_ai}\");\n        }\n    }\n}\n\npub async fn init(\n    aliases: AliasStore,\n    vars: VarStore,\n    disable_up_arrow: bool,\n    disable_ctrl_r: bool,\n    disable_ai: bool,\n    tmux: &Tmux,\n) -> Result<()> {\n    init_static(disable_up_arrow, disable_ctrl_r, disable_ai, tmux);\n\n    let aliases = atuin_dotfiles::shell::zsh::alias_config(&aliases).await;\n    let vars = atuin_dotfiles::shell::zsh::var_config(&vars).await;\n\n    println!(\"{aliases}\");\n    println!(\"{vars}\");\n\n    Ok(())\n}\n"
  },
  {
    "path": "crates/atuin/src/command/client/init.rs",
    "content": "use std::path::PathBuf;\n\nuse atuin_client::{\n    encryption,\n    record::sqlite_store::SqliteStore,\n    settings::{Settings, Tmux},\n};\nuse atuin_dotfiles::store::{AliasStore, var::VarStore};\nuse clap::{Parser, ValueEnum};\nuse eyre::{Result, WrapErr};\n\nmod bash;\nmod fish;\nmod powershell;\nmod xonsh;\nmod zsh;\n\n#[derive(Parser, Debug)]\npub struct Cmd {\n    shell: Shell,\n\n    /// Disable the binding of CTRL-R to atuin\n    #[clap(long)]\n    disable_ctrl_r: bool,\n\n    /// Disable the binding of the Up Arrow key to atuin\n    #[clap(long)]\n    disable_up_arrow: bool,\n\n    /// Disable the binding of ? to Atuin AI\n    #[clap(long)]\n    disable_ai: bool,\n}\n\n#[derive(Clone, Copy, ValueEnum, Debug)]\n#[value(rename_all = \"lower\")]\n#[allow(clippy::enum_variant_names, clippy::doc_markdown)]\npub enum Shell {\n    /// Zsh setup\n    Zsh,\n    /// Bash setup\n    Bash,\n    /// Fish setup\n    Fish,\n    /// Nu setup\n    Nu,\n    /// Xonsh setup\n    Xonsh,\n    /// PowerShell setup\n    PowerShell,\n}\n\nimpl Cmd {\n    fn init_nu(&self, _tmux: &Tmux) {\n        let full = include_str!(\"../../shell/atuin.nu\");\n\n        // TODO: tmux popup for Nu\n        println!(\"{full}\");\n\n        if std::env::var(\"ATUIN_NOBIND\").is_err() {\n            const BIND_CTRL_R: &str = r\"$env.config = (\n    $env.config | upsert keybindings (\n        $env.config.keybindings\n        | append {\n            name: atuin\n            modifier: control\n            keycode: char_r\n            mode: [emacs, vi_normal, vi_insert]\n            event: { send: executehostcommand cmd: (_atuin_search_cmd) }\n        }\n    )\n)\";\n            const BIND_UP_ARROW: &str = r\"\n$env.config = (\n    $env.config | upsert keybindings (\n        $env.config.keybindings\n        | append {\n            name: atuin\n            modifier: none\n            keycode: up\n            mode: [emacs, vi_normal, vi_insert]\n            event: {\n                until: [\n                    {send: menuup}\n                    {send: executehostcommand cmd: (_atuin_search_cmd '--shell-up-key-binding') }\n                ]\n            }\n        }\n    )\n)\n\";\n            if !self.disable_ctrl_r {\n                println!(\"{BIND_CTRL_R}\");\n            }\n            if !self.disable_up_arrow {\n                println!(\"{BIND_UP_ARROW}\");\n            }\n        }\n    }\n\n    fn static_init(&self, tmux: &Tmux) {\n        match self.shell {\n            Shell::Zsh => {\n                zsh::init_static(\n                    self.disable_up_arrow,\n                    self.disable_ctrl_r,\n                    self.disable_ai,\n                    tmux,\n                );\n            }\n            Shell::Bash => {\n                bash::init_static(\n                    self.disable_up_arrow,\n                    self.disable_ctrl_r,\n                    self.disable_ai,\n                    tmux,\n                );\n            }\n            Shell::Fish => {\n                fish::init_static(\n                    self.disable_up_arrow,\n                    self.disable_ctrl_r,\n                    self.disable_ai,\n                    tmux,\n                );\n            }\n            Shell::Nu => {\n                self.init_nu(tmux);\n            }\n            Shell::Xonsh => {\n                xonsh::init_static(self.disable_up_arrow, self.disable_ctrl_r, tmux);\n            }\n            Shell::PowerShell => {\n                powershell::init_static(self.disable_up_arrow, self.disable_ctrl_r, tmux);\n            }\n        }\n    }\n\n    async fn dotfiles_init(&self, settings: &Settings) -> Result<()> {\n        let record_store_path = PathBuf::from(settings.record_store_path.as_str());\n        let sqlite_store = SqliteStore::new(record_store_path, settings.local_timeout).await?;\n\n        let encryption_key: [u8; 32] = encryption::load_key(settings)\n            .context(\"could not load encryption key\")?\n            .into();\n        let host_id = Settings::host_id().await?;\n\n        let alias_store = AliasStore::new(sqlite_store.clone(), host_id, encryption_key);\n        let var_store = VarStore::new(sqlite_store.clone(), host_id, encryption_key);\n\n        match self.shell {\n            Shell::Zsh => {\n                zsh::init(\n                    alias_store,\n                    var_store,\n                    self.disable_up_arrow,\n                    self.disable_ctrl_r,\n                    self.disable_ai,\n                    &settings.tmux,\n                )\n                .await?;\n            }\n            Shell::Bash => {\n                bash::init(\n                    alias_store,\n                    var_store,\n                    self.disable_up_arrow,\n                    self.disable_ctrl_r,\n                    self.disable_ai,\n                    &settings.tmux,\n                )\n                .await?;\n            }\n            Shell::Fish => {\n                fish::init(\n                    alias_store,\n                    var_store,\n                    self.disable_up_arrow,\n                    self.disable_ctrl_r,\n                    self.disable_ai,\n                    &settings.tmux,\n                )\n                .await?;\n            }\n            Shell::Nu => self.init_nu(&settings.tmux),\n            Shell::Xonsh => {\n                xonsh::init(\n                    alias_store,\n                    var_store,\n                    self.disable_up_arrow,\n                    self.disable_ctrl_r,\n                    &settings.tmux,\n                )\n                .await?;\n            }\n            Shell::PowerShell => {\n                powershell::init(\n                    alias_store,\n                    var_store,\n                    self.disable_up_arrow,\n                    self.disable_ctrl_r,\n                    &settings.tmux,\n                )\n                .await?;\n            }\n        }\n\n        Ok(())\n    }\n\n    pub async fn run(self, settings: &Settings) -> Result<()> {\n        if !settings.paths_ok() {\n            eprintln!(\n                \"Atuin settings paths are broken. Disabling atuin shell hooks. Run `atuin doctor` to diagnose.\"\n            );\n            return Ok(());\n        }\n\n        if settings.dotfiles.enabled {\n            self.dotfiles_init(settings).await?;\n        } else {\n            self.static_init(&settings.tmux);\n        }\n\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "crates/atuin/src/command/client/kv.rs",
    "content": "use std::io::{self, IsTerminal, Read};\n\nuse clap::Subcommand;\nuse eyre::{Context, Result, eyre};\n\nuse atuin_client::{encryption, record::sqlite_store::SqliteStore, settings::Settings};\nuse atuin_kv::store::KvStore;\n\n#[derive(Subcommand, Debug)]\n#[command(infer_subcommands = true)]\npub enum Cmd {\n    /// Set a key-value pair\n    Set {\n        /// Key to set\n        #[arg(long, short)]\n        key: String,\n\n        /// Value to store (reads from stdin if not provided)\n        value: Option<String>,\n\n        /// Namespace for the key-value pair\n        #[arg(long, short, default_value = \"default\")]\n        namespace: String,\n    },\n\n    /// Delete one or more key-value pairs\n    #[command(alias = \"rm\")]\n    Delete {\n        /// Keys to delete\n        #[arg(required = true)]\n        keys: Vec<String>,\n\n        /// Namespace for the key-value pair\n        #[arg(long, short, default_value = \"default\")]\n        namespace: String,\n    },\n\n    /// Retrieve a saved value\n    Get {\n        /// Key to retrieve\n        key: String,\n\n        /// Namespace for the key-value pair\n        #[arg(long, short, default_value = \"default\")]\n        namespace: String,\n    },\n\n    /// List all keys in a namespace, or in all namespaces\n    #[command(alias = \"ls\")]\n    List {\n        /// Namespace to list keys from\n        #[arg(long, short, default_value = \"default\")]\n        namespace: String,\n\n        /// List all keys in all namespaces\n        #[arg(long, short, alias = \"all\")]\n        all_namespaces: bool,\n    },\n\n    /// Rebuild the KV store\n    Rebuild,\n}\n\nimpl Cmd {\n    pub async fn run(&self, settings: &Settings, store: &SqliteStore) -> Result<()> {\n        let encryption_key: [u8; 32] = encryption::load_key(settings)\n            .context(\"could not load encryption key\")?\n            .into();\n\n        let host_id = Settings::host_id().await?;\n\n        let kv_db = atuin_kv::database::Database::new(settings.kv.db_path.clone(), 1.0).await?;\n        let kv_store = KvStore::new(store.clone(), kv_db, host_id, encryption_key);\n\n        match self {\n            Self::Set {\n                key,\n                value,\n                namespace,\n            } => {\n                if namespace.is_empty() {\n                    return Err(eyre!(\"namespace cannot be empty\"));\n                }\n\n                let value = if let Some(v) = value {\n                    v.clone()\n                } else if !io::stdin().is_terminal() {\n                    let mut buf = String::new();\n                    io::stdin()\n                        .read_to_string(&mut buf)\n                        .context(\"failed to read value from stdin\")?;\n                    buf\n                } else {\n                    return Err(eyre!(\n                        \"no value provided. Pass as an argument or pipe via stdin\"\n                    ));\n                };\n\n                kv_store.set(namespace, key, &value).await\n            }\n\n            Self::Delete { keys, namespace } => kv_store.delete(namespace, keys).await,\n\n            Self::Get { key, namespace } => {\n                let kv = kv_store.get(namespace, key).await?;\n\n                if let Some(val) = kv {\n                    println!(\"{val}\");\n                }\n\n                Ok(())\n            }\n\n            Self::List {\n                namespace,\n                all_namespaces,\n            } => {\n                let entries = if *all_namespaces {\n                    kv_store.list(None).await?\n                } else {\n                    kv_store.list(Some(namespace)).await?\n                };\n\n                for entry in entries {\n                    if *all_namespaces {\n                        println!(\"{}.{}\", entry.namespace, entry.key);\n                    } else {\n                        println!(\"{}\", entry.key);\n                    }\n                }\n\n                Ok(())\n            }\n\n            Self::Rebuild {} => kv_store.build().await,\n        }\n    }\n}\n"
  },
  {
    "path": "crates/atuin/src/command/client/scripts.rs",
    "content": "use std::collections::HashMap;\nuse std::collections::HashSet;\nuse std::io::IsTerminal;\nuse std::io::Read;\nuse std::path::PathBuf;\n\nuse atuin_scripts::execution::template_script;\nuse atuin_scripts::{\n    execution::{build_executable_script, execute_script_interactive, template_variables},\n    store::{ScriptStore, script::Script},\n};\nuse clap::{Parser, Subcommand};\nuse eyre::OptionExt;\nuse eyre::{Result, bail};\nuse tempfile::NamedTempFile;\n\nuse atuin_client::{database::Database, record::sqlite_store::SqliteStore, settings::Settings};\nuse tracing::debug;\n\n#[derive(Parser, Debug)]\npub struct NewScript {\n    pub name: String,\n\n    #[arg(short, long)]\n    pub description: Option<String>,\n\n    #[arg(short, long)]\n    pub tags: Vec<String>,\n\n    #[arg(short, long)]\n    pub shebang: Option<String>,\n\n    #[arg(long)]\n    pub script: Option<PathBuf>,\n\n    #[allow(clippy::option_option)]\n    #[arg(long)]\n    /// Use the last command as the script content\n    /// Optionally specify a number to use the last N commands\n    pub last: Option<Option<usize>>,\n\n    #[arg(long)]\n    /// Skip opening editor when using --last\n    pub no_edit: bool,\n}\n\n#[derive(Parser, Debug)]\npub struct Run {\n    pub name: String,\n\n    /// Specify template variables in the format KEY=VALUE\n    /// Example: -v name=John -v greeting=\"Hello there\"\n    #[arg(short, long = \"var\")]\n    pub var: Vec<String>,\n}\n\n#[derive(Parser, Debug)]\npub struct List {}\n\n#[derive(Parser, Debug)]\npub struct Get {\n    pub name: String,\n\n    #[arg(short, long)]\n    /// Display only the executable script with shebang\n    pub script: bool,\n}\n\n#[derive(Parser, Debug)]\npub struct Edit {\n    pub name: String,\n\n    #[arg(short, long)]\n    pub description: Option<String>,\n\n    /// Replace all existing tags with these new tags\n    #[arg(short, long)]\n    pub tags: Vec<String>,\n\n    /// Remove all tags from the script\n    #[arg(long)]\n    pub no_tags: bool,\n\n    /// Rename the script\n    #[arg(long)]\n    pub rename: Option<String>,\n\n    #[arg(short, long)]\n    pub shebang: Option<String>,\n\n    #[arg(long)]\n    pub script: Option<PathBuf>,\n\n    #[allow(clippy::struct_field_names)]\n    /// Skip opening editor\n    #[arg(long)]\n    pub no_edit: bool,\n}\n\n#[derive(Parser, Debug)]\npub struct Delete {\n    pub name: String,\n\n    #[arg(short, long)]\n    pub force: bool,\n}\n\n#[derive(Subcommand, Debug)]\n#[command(infer_subcommands = true)]\npub enum Cmd {\n    New(NewScript),\n    Run(Run),\n    #[command(alias = \"ls\")]\n    List(List),\n\n    Get(Get),\n    Edit(Edit),\n    #[command(alias = \"rm\")]\n    Delete(Delete),\n}\n\nimpl Cmd {\n    // Helper function to open an editor with optional initial content\n    fn open_editor(initial_content: Option<&str>) -> Result<String> {\n        // Create a temporary file\n        let temp_file = NamedTempFile::new()?;\n        let path = temp_file.into_temp_path();\n\n        // Write initial content to the temp file if provided\n        if let Some(content) = initial_content {\n            std::fs::write(&path, content)?;\n        }\n\n        // Open the file in the user's preferred editor\n        let editor_str = std::env::var(\"EDITOR\").unwrap_or_else(|_| \"vi\".to_string());\n\n        // Use shlex to safely split the string into shell-like parts.\n        let parts = shlex::split(&editor_str).ok_or_eyre(\"Failed to parse editor command\")?;\n        let (command, args) = parts.split_first().ok_or_eyre(\"No editor command found\")?;\n\n        let status = std::process::Command::new(command)\n            .args(args)\n            .arg(&path)\n            .status()?;\n        if !status.success() {\n            bail!(\"failed to open editor\");\n        }\n\n        // Read back the edited content\n        let content = std::fs::read_to_string(&path)?;\n        path.close()?;\n\n        Ok(content)\n    }\n\n    // Helper function to execute a script and manage stdin/stdout/stderr\n    async fn execute_script(script_content: String, shebang: String) -> Result<i32> {\n        let mut session = execute_script_interactive(script_content, shebang)\n            .await\n            .expect(\"failed to execute script\");\n\n        // Create a channel to signal when the process exits\n        let (exit_tx, mut exit_rx) = tokio::sync::oneshot::channel();\n\n        // Set up a task to read from stdin and forward to the script\n        let sender = session.stdin_tx.clone();\n        let stdin_task = tokio::spawn(async move {\n            use tokio::io::AsyncReadExt;\n            use tokio::select;\n\n            let stdin = tokio::io::stdin();\n            let mut reader = tokio::io::BufReader::new(stdin);\n            let mut buffer = vec![0u8; 1024]; // Read in chunks for efficiency\n\n            loop {\n                // Use select to either read from stdin or detect when the process exits\n                select! {\n                    // Check if the script process has exited\n                    _ = &mut exit_rx => {\n                        break;\n                    }\n                    // Try to read from stdin\n                    read_result = reader.read(&mut buffer) => {\n                        match read_result {\n                            Ok(0) => break, // EOF\n                            Ok(n) => {\n                                // Convert the bytes to a string and forward to script\n                                let input = String::from_utf8_lossy(&buffer[0..n]).to_string();\n                                if let Err(e) = sender.send(input).await {\n                                    eprintln!(\"Error sending input to script: {e}\");\n                                    break;\n                                }\n                            },\n                            Err(e) => {\n                                eprintln!(\"Error reading from stdin: {e}\");\n                                break;\n                            }\n                        }\n                    }\n                }\n            }\n        });\n\n        // Wait for the script to complete\n        let exit_code = session.wait_for_exit().await;\n\n        // Signal the stdin task to stop\n        let _ = exit_tx.send(());\n        let _ = stdin_task.await;\n\n        let code = exit_code.unwrap_or(-1);\n        if code != 0 {\n            eprintln!(\"Script exited with code {code}\");\n        }\n\n        Ok(code)\n    }\n\n    async fn handle_new_script(\n        settings: &Settings,\n        new_script: NewScript,\n        script_store: ScriptStore,\n        script_db: atuin_scripts::database::Database,\n        history_db: &impl Database,\n    ) -> Result<()> {\n        let mut stdin = std::io::stdin();\n        let script_content = if let Some(count_opt) = new_script.last {\n            // Get the last N commands from history, plus 1 to exclude the command that runs this script\n            let count = count_opt.unwrap_or(1) + 1; // Add 1 to the count to exclude the current command\n            let context = atuin_client::database::current_context().await?;\n\n            // Get the last N+1 commands, filtering by the default mode\n            let filters = [settings.default_filter_mode(context.git_root.is_some())];\n\n            let mut history = history_db\n                .list(&filters, &context, Some(count), false, false)\n                .await?;\n\n            // Reverse to get chronological order\n            history.reverse();\n\n            // Skip the most recent command (which would be the atuin scripts new command itself)\n            if !history.is_empty() {\n                history.pop(); // Remove the most recent command\n            }\n\n            // Format the commands into a script\n            let commands: Vec<String> = history.iter().map(|h| h.command.clone()).collect();\n\n            if commands.is_empty() {\n                bail!(\"No commands found in history\");\n            }\n\n            let script_text = commands.join(\"\\n\");\n\n            // Only open editor if --no-edit is not specified\n            if new_script.no_edit {\n                Some(script_text)\n            } else {\n                // Open the editor with the commands pre-loaded\n                Some(Self::open_editor(Some(&script_text))?)\n            }\n        } else if let Some(script_path) = new_script.script {\n            let script_content = std::fs::read_to_string(script_path)?;\n            Some(script_content)\n        } else if !stdin.is_terminal() {\n            let mut buffer = String::new();\n            stdin.read_to_string(&mut buffer)?;\n            Some(buffer)\n        } else {\n            // Open editor with empty file\n            Some(Self::open_editor(None)?)\n        };\n\n        let script = Script::builder()\n            .name(new_script.name)\n            .description(new_script.description.unwrap_or_default())\n            .shebang(new_script.shebang.unwrap_or_default())\n            .tags(new_script.tags)\n            .script(script_content.unwrap_or_default())\n            .build();\n\n        script_store.create(script).await?;\n\n        script_store.build(script_db).await?;\n\n        Ok(())\n    }\n\n    async fn handle_run(\n        _settings: &Settings,\n        run: Run,\n        script_db: atuin_scripts::database::Database,\n    ) -> Result<()> {\n        let script = script_db.get_by_name(&run.name).await?;\n\n        if let Some(script) = script {\n            // Get variables used in the template\n            let variables = template_variables(&script)?;\n\n            // Create a hashmap to store variable values\n            let mut variable_values: HashMap<String, serde_json::Value> = HashMap::new();\n\n            // Parse variables from command-line arguments first\n            for var_str in &run.var {\n                if let Some((key, value)) = var_str.split_once('=') {\n                    // Add to variable values\n                    variable_values.insert(\n                        key.to_string(),\n                        serde_json::Value::String(value.to_string()),\n                    );\n                    debug!(\"Using CLI variable: {}={}\", key, value);\n                } else {\n                    eprintln!(\"Warning: Ignoring malformed variable specification: {var_str}\");\n                    eprintln!(\"Variables should be specified as KEY=VALUE\");\n                }\n            }\n\n            // Collect variables that are still needed (not specified via CLI)\n            let remaining_vars: HashSet<String> = variables\n                .into_iter()\n                .filter(|var| !variable_values.contains_key(var))\n                .collect();\n\n            // If there are variables in the template that weren't specified on the command line, prompt for them\n            if !remaining_vars.is_empty() {\n                println!(\"This script contains template variables that need values:\");\n\n                let stdin = std::io::stdin();\n                let mut input = String::new();\n\n                for var in remaining_vars {\n                    input.clear();\n\n                    println!(\"Enter value for '{var}': \");\n\n                    if stdin.read_line(&mut input).is_err() {\n                        eprintln!(\"Failed to read input for variable '{var}'\");\n                        // Provide an empty string as fallback\n                        variable_values.insert(var, serde_json::Value::String(String::new()));\n                        continue;\n                    }\n\n                    let value = input.trim().to_string();\n                    variable_values.insert(var, serde_json::Value::String(value));\n                }\n            }\n\n            let final_script = if variable_values.is_empty() {\n                // No variables to template, just use the original script\n                script.script.clone()\n            } else {\n                // If we have variables, we need to template the script\n                debug!(\"Templating script with variables: {:?}\", variable_values);\n                template_script(&script, &variable_values)?\n            };\n\n            // Execute the script (either templated or original)\n            Self::execute_script(final_script, script.shebang.clone()).await?;\n        } else {\n            bail!(\"script not found\");\n        }\n        Ok(())\n    }\n\n    async fn handle_list(\n        _settings: &Settings,\n        _list: List,\n        script_db: atuin_scripts::database::Database,\n    ) -> Result<()> {\n        let scripts = script_db.list().await?;\n\n        if scripts.is_empty() {\n            println!(\"No scripts found\");\n        } else {\n            println!(\"Available scripts:\");\n            for script in scripts {\n                if script.tags.is_empty() {\n                    println!(\"- {} \", script.name);\n                } else {\n                    println!(\"- {} [tags: {}]\", script.name, script.tags.join(\", \"));\n                }\n\n                // Print description if it's not empty\n                if !script.description.is_empty() {\n                    println!(\"  Description: {}\", script.description);\n                }\n            }\n        }\n\n        Ok(())\n    }\n\n    async fn handle_get(\n        _settings: &Settings,\n        get: Get,\n        script_db: atuin_scripts::database::Database,\n    ) -> Result<()> {\n        let script = script_db.get_by_name(&get.name).await?;\n\n        if let Some(script) = script {\n            if get.script {\n                // Just print the executable script with shebang\n                print!(\n                    \"{}\",\n                    build_executable_script(script.script.clone(), script.shebang)\n                );\n                return Ok(());\n            }\n\n            // Create a YAML representation of the script\n            println!(\"---\");\n            println!(\"name: {}\", script.name);\n            println!(\"id: {}\", script.id);\n\n            if script.description.is_empty() {\n                println!(\"description: \\\"\\\"\");\n            } else {\n                println!(\"description: |\");\n                // Indent multiline descriptions properly for YAML\n                for line in script.description.lines() {\n                    println!(\"  {line}\");\n                }\n            }\n\n            if script.tags.is_empty() {\n                println!(\"tags: []\");\n            } else {\n                println!(\"tags:\");\n                for tag in &script.tags {\n                    println!(\"  - {tag}\");\n                }\n            }\n\n            println!(\"shebang: {}\", script.shebang);\n\n            println!(\"script: |\");\n            // Indent the script content for proper YAML multiline format\n            for line in script.script.lines() {\n                println!(\"  {line}\");\n            }\n\n            Ok(())\n        } else {\n            bail!(\"script '{}' not found\", get.name);\n        }\n    }\n\n    #[allow(clippy::cognitive_complexity)]\n    async fn handle_edit(\n        _settings: &Settings,\n        edit: Edit,\n        script_store: ScriptStore,\n        script_db: atuin_scripts::database::Database,\n    ) -> Result<()> {\n        debug!(\"editing script {:?}\", edit);\n        // Find the existing script\n        let existing_script = script_db.get_by_name(&edit.name).await?;\n        debug!(\"existing script {:?}\", existing_script);\n\n        if let Some(mut script) = existing_script {\n            // Update the script with new values if provided\n            if let Some(description) = edit.description {\n                script.description = description;\n            }\n\n            // Handle renaming if requested\n            if let Some(new_name) = edit.rename {\n                // Check if a script with the new name already exists\n                if (script_db.get_by_name(&new_name).await?).is_some() {\n                    bail!(\"A script named '{}' already exists\", new_name);\n                }\n\n                // Update the name\n                script.name = new_name;\n            }\n\n            // Handle tag updates with priority:\n            // 1. If --no-tags is provided, clear all tags\n            // 2. If --tags is provided, replace all tags\n            // 3. If neither is provided, tags remain unchanged\n            if edit.no_tags {\n                // Clear all tags\n                script.tags.clear();\n            } else if !edit.tags.is_empty() {\n                // Replace all tags\n                script.tags = edit.tags;\n            }\n            // If none of the above conditions are met, tags remain unchanged\n\n            if let Some(shebang) = edit.shebang {\n                script.shebang = shebang;\n            }\n\n            // Handle script content update\n            let script_content = if let Some(script_path) = edit.script {\n                // Load script from provided file\n                std::fs::read_to_string(script_path)?\n            } else if !edit.no_edit {\n                // Open the script in editor for interactive editing if --no-edit is not specified\n                Self::open_editor(Some(&script.script))?\n            } else {\n                // If --no-edit is specified, keep the existing script content\n                script.script.clone()\n            };\n\n            // Update the script content\n            script.script = script_content;\n\n            // Update the script in the store\n            script_store.update(script).await?;\n\n            // Rebuild the database to apply changes\n            script_store.build(script_db).await?;\n\n            println!(\"Script '{}' updated successfully!\", edit.name);\n\n            Ok(())\n        } else {\n            bail!(\"script '{}' not found\", edit.name);\n        }\n    }\n\n    async fn handle_delete(\n        _settings: &Settings,\n        delete: Delete,\n        script_store: ScriptStore,\n        script_db: atuin_scripts::database::Database,\n    ) -> Result<()> {\n        // Find the script by name\n        let script = script_db.get_by_name(&delete.name).await?;\n\n        if let Some(script) = script {\n            // If not force, confirm deletion\n            if !delete.force {\n                println!(\n                    \"Are you sure you want to delete script '{}'? [y/N]\",\n                    delete.name\n                );\n                let mut input = String::new();\n                std::io::stdin().read_line(&mut input)?;\n\n                let input = input.trim().to_lowercase();\n                if input != \"y\" && input != \"yes\" {\n                    println!(\"Deletion cancelled\");\n                    return Ok(());\n                }\n            }\n\n            // Delete the script\n            script_store.delete(script.id).await?;\n\n            // Rebuild the database to apply changes\n            script_store.build(script_db).await?;\n\n            println!(\"Script '{}' deleted successfully\", delete.name);\n            Ok(())\n        } else {\n            bail!(\"script '{}' not found\", delete.name);\n        }\n    }\n\n    pub async fn run(\n        self,\n        settings: &Settings,\n        store: SqliteStore,\n        history_db: &impl Database,\n    ) -> Result<()> {\n        let host_id = Settings::host_id().await?;\n        let encryption_key: [u8; 32] = atuin_client::encryption::load_key(settings)?.into();\n\n        let script_store = ScriptStore::new(store, host_id, encryption_key);\n        let script_db =\n            atuin_scripts::database::Database::new(settings.scripts.db_path.clone(), 1.0).await?;\n\n        match self {\n            Self::New(new_script) => {\n                Self::handle_new_script(settings, new_script, script_store, script_db, history_db)\n                    .await\n            }\n            Self::Run(run) => Self::handle_run(settings, run, script_db).await,\n            Self::List(list) => Self::handle_list(settings, list, script_db).await,\n            Self::Get(get) => Self::handle_get(settings, get, script_db).await,\n            Self::Edit(edit) => Self::handle_edit(settings, edit, script_store, script_db).await,\n            Self::Delete(delete) => {\n                Self::handle_delete(settings, delete, script_store, script_db).await\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "crates/atuin/src/command/client/search/cursor.rs",
    "content": "use atuin_client::settings::WordJumpMode;\n\npub struct Cursor {\n    source: String,\n    index: usize,\n}\n\nimpl From<String> for Cursor {\n    fn from(source: String) -> Self {\n        Self { source, index: 0 }\n    }\n}\n\npub struct WordJumper<'a> {\n    word_chars: &'a str,\n    word_jump_mode: WordJumpMode,\n}\n\nimpl WordJumper<'_> {\n    fn is_word_boundary(&self, c: char, next_c: char) -> bool {\n        (c.is_whitespace() && !next_c.is_whitespace())\n            || (!c.is_whitespace() && next_c.is_whitespace())\n            || (self.word_chars.contains(c) && !self.word_chars.contains(next_c))\n            || (!self.word_chars.contains(c) && self.word_chars.contains(next_c))\n    }\n\n    fn emacs_get_next_word_pos(&self, source: &str, index: usize) -> usize {\n        let index = (index + 1..source.len().saturating_sub(1))\n            .find(|&i| self.word_chars.contains(source.chars().nth(i).unwrap()))\n            .unwrap_or(source.len());\n        (index + 1..source.len().saturating_sub(1))\n            .find(|&i| !self.word_chars.contains(source.chars().nth(i).unwrap()))\n            .unwrap_or(source.len())\n    }\n\n    fn emacs_get_prev_word_pos(&self, source: &str, index: usize) -> usize {\n        let index = (1..index)\n            .rev()\n            .find(|&i| self.word_chars.contains(source.chars().nth(i).unwrap()))\n            .unwrap_or(0);\n        (1..index)\n            .rev()\n            .find(|&i| !self.word_chars.contains(source.chars().nth(i).unwrap()))\n            .map_or(0, |i| i + 1)\n    }\n\n    fn subl_get_next_word_pos(&self, source: &str, index: usize) -> usize {\n        let index = (index..source.len().saturating_sub(1)).find(|&i| {\n            self.is_word_boundary(\n                source.chars().nth(i).unwrap(),\n                source.chars().nth(i + 1).unwrap(),\n            )\n        });\n        if index.is_none() {\n            return source.len();\n        }\n        (index.unwrap() + 1..source.len())\n            .find(|&i| !source.chars().nth(i).unwrap().is_whitespace())\n            .unwrap_or(source.len())\n    }\n\n    fn subl_get_prev_word_pos(&self, source: &str, index: usize) -> usize {\n        let index = (1..index)\n            .rev()\n            .find(|&i| !source.chars().nth(i).unwrap().is_whitespace());\n        if index.is_none() {\n            return 0;\n        }\n        (1..index.unwrap())\n            .rev()\n            .find(|&i| {\n                self.is_word_boundary(\n                    source.chars().nth(i - 1).unwrap(),\n                    source.chars().nth(i).unwrap(),\n                )\n            })\n            .unwrap_or(0)\n    }\n\n    fn get_next_word_pos(&self, source: &str, index: usize) -> usize {\n        match self.word_jump_mode {\n            WordJumpMode::Emacs => self.emacs_get_next_word_pos(source, index),\n            WordJumpMode::Subl => self.subl_get_next_word_pos(source, index),\n        }\n    }\n\n    fn get_prev_word_pos(&self, source: &str, index: usize) -> usize {\n        match self.word_jump_mode {\n            WordJumpMode::Emacs => self.emacs_get_prev_word_pos(source, index),\n            WordJumpMode::Subl => self.subl_get_prev_word_pos(source, index),\n        }\n    }\n}\n\nimpl Cursor {\n    pub fn as_str(&self) -> &str {\n        self.source.as_str()\n    }\n\n    pub fn into_inner(self) -> String {\n        self.source\n    }\n\n    /// Returns the string before the cursor\n    pub fn substring(&self) -> &str {\n        &self.source[..self.index]\n    }\n\n    /// Returns the currently selected [`char`]\n    pub fn char(&self) -> Option<char> {\n        self.source[self.index..].chars().next()\n    }\n\n    pub fn right(&mut self) {\n        if self.index < self.source.len() {\n            loop {\n                self.index += 1;\n                if self.source.is_char_boundary(self.index) {\n                    break;\n                }\n            }\n        }\n    }\n\n    pub fn left(&mut self) -> bool {\n        if self.index > 0 {\n            loop {\n                self.index -= 1;\n                if self.source.is_char_boundary(self.index) {\n                    break true;\n                }\n            }\n        } else {\n            false\n        }\n    }\n\n    pub fn next_word(&mut self, word_chars: &str, word_jump_mode: WordJumpMode) {\n        let word_jumper = WordJumper {\n            word_chars,\n            word_jump_mode,\n        };\n        self.index = word_jumper.get_next_word_pos(&self.source, self.index);\n    }\n\n    pub fn prev_word(&mut self, word_chars: &str, word_jump_mode: WordJumpMode) {\n        let word_jumper = WordJumper {\n            word_chars,\n            word_jump_mode,\n        };\n        self.index = word_jumper.get_prev_word_pos(&self.source, self.index);\n    }\n\n    /// Move cursor to the end of the current/next word (vim `e` motion).\n    ///\n    /// If cursor is in the middle of a word, moves to the end of that word.\n    /// If cursor is at the end of a word (or on whitespace), moves to the\n    /// end of the next word.\n    pub fn word_end(&mut self, word_chars: &str) {\n        let len = self.source.len();\n        if self.index >= len {\n            return;\n        }\n\n        let chars: Vec<char> = self.source.chars().collect();\n        let mut char_idx = self.source[..self.index].chars().count();\n\n        if char_idx >= chars.len() {\n            return;\n        }\n\n        let current = chars[char_idx];\n\n        // Check if we're at a word boundary (end of current word or on whitespace)\n        let at_word_boundary = current.is_whitespace() || char_idx + 1 >= chars.len() || {\n            let next = chars[char_idx + 1];\n            next.is_whitespace() || (word_chars.contains(current) != word_chars.contains(next))\n        };\n\n        // If at word boundary, advance past it and skip whitespace to find next word\n        if at_word_boundary {\n            char_idx += 1;\n            while char_idx < chars.len() && chars[char_idx].is_whitespace() {\n                char_idx += 1;\n            }\n        }\n\n        // If we've gone past end, go to end of string\n        if char_idx >= chars.len() {\n            self.index = len;\n            return;\n        }\n\n        // Find end of word: advance until next char is whitespace or different word type\n        let in_word_chars = word_chars.contains(chars[char_idx]);\n        while char_idx < chars.len() {\n            let next_idx = char_idx + 1;\n            if next_idx >= chars.len() {\n                // At last char, move past it\n                char_idx = next_idx;\n                break;\n            }\n            let next_c = chars[next_idx];\n            if next_c.is_whitespace() || (word_chars.contains(next_c) != in_word_chars) {\n                // Next char is start of new word/whitespace, so current char is end\n                char_idx = next_idx;\n                break;\n            }\n            char_idx += 1;\n        }\n\n        // Convert char index back to byte index\n        self.index = chars.iter().take(char_idx).map(|c| c.len_utf8()).sum();\n    }\n\n    pub fn insert(&mut self, c: char) {\n        self.source.insert(self.index, c);\n        self.index += c.len_utf8();\n    }\n\n    pub fn remove(&mut self) -> Option<char> {\n        if self.index < self.source.len() {\n            Some(self.source.remove(self.index))\n        } else {\n            None\n        }\n    }\n\n    pub fn remove_next_word(&mut self, word_chars: &str, word_jump_mode: WordJumpMode) {\n        let word_jumper = WordJumper {\n            word_chars,\n            word_jump_mode,\n        };\n        let next_index = word_jumper.get_next_word_pos(&self.source, self.index);\n        self.source.replace_range(self.index..next_index, \"\");\n    }\n\n    pub fn remove_prev_word(&mut self, word_chars: &str, word_jump_mode: WordJumpMode) {\n        let word_jumper = WordJumper {\n            word_chars,\n            word_jump_mode,\n        };\n        let next_index = word_jumper.get_prev_word_pos(&self.source, self.index);\n        self.source.replace_range(next_index..self.index, \"\");\n        self.index = next_index;\n    }\n\n    pub fn back(&mut self) -> Option<char> {\n        if self.left() { self.remove() } else { None }\n    }\n\n    pub fn clear(&mut self) {\n        self.source.clear();\n        self.index = 0;\n    }\n\n    pub fn clear_to_start(&mut self) {\n        self.source.replace_range(..self.index, \"\");\n        self.index = 0;\n    }\n\n    pub fn clear_to_end(&mut self) {\n        self.source.replace_range(self.index.., \"\");\n        self.index = self.source.len();\n    }\n\n    pub fn end(&mut self) {\n        self.index = self.source.len();\n    }\n\n    pub fn start(&mut self) {\n        self.index = 0;\n    }\n\n    pub fn position(&self) -> usize {\n        self.index\n    }\n}\n\n#[cfg(test)]\nmod cursor_tests {\n    use super::Cursor;\n    use super::*;\n\n    static EMACS_WORD_JUMPER: WordJumper = WordJumper {\n        word_chars: \"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789\",\n        word_jump_mode: WordJumpMode::Emacs,\n    };\n\n    static SUBL_WORD_JUMPER: WordJumper = WordJumper {\n        word_chars: \"./\\\\()\\\"'-:,.;<>~!@#$%^&*|+=[]{}`~?\",\n        word_jump_mode: WordJumpMode::Subl,\n    };\n\n    #[test]\n    fn right() {\n        // ö is 2 bytes\n        let mut c = Cursor::from(String::from(\"öaöböcödöeöfö\"));\n        let indices = [0, 2, 3, 5, 6, 8, 9, 11, 12, 14, 15, 17, 18, 20, 20, 20, 20];\n        for i in indices {\n            assert_eq!(c.index, i);\n            c.right();\n        }\n    }\n\n    #[test]\n    fn left() {\n        // ö is 2 bytes\n        let mut c = Cursor::from(String::from(\"öaöböcödöeöfö\"));\n        c.end();\n        let indices = [20, 18, 17, 15, 14, 12, 11, 9, 8, 6, 5, 3, 2, 0, 0, 0, 0];\n        for i in indices {\n            assert_eq!(c.index, i);\n            c.left();\n        }\n    }\n\n    #[test]\n    fn test_emacs_get_next_word_pos() {\n        let s = String::from(\"   aaa   ((()))bbb   ((()))   \");\n        let indices = [(0, 6), (3, 6), (7, 18), (19, 30)];\n        for (i_src, i_dest) in indices {\n            assert_eq!(EMACS_WORD_JUMPER.get_next_word_pos(&s, i_src), i_dest);\n        }\n        assert_eq!(EMACS_WORD_JUMPER.get_next_word_pos(\"\", 0), 0);\n    }\n\n    #[test]\n    fn test_emacs_get_prev_word_pos() {\n        let s = String::from(\"   aaa   ((()))bbb   ((()))   \");\n        let indices = [(30, 15), (29, 15), (15, 3), (3, 0)];\n        for (i_src, i_dest) in indices {\n            assert_eq!(EMACS_WORD_JUMPER.get_prev_word_pos(&s, i_src), i_dest);\n        }\n        assert_eq!(EMACS_WORD_JUMPER.get_prev_word_pos(\"\", 0), 0);\n    }\n\n    #[test]\n    fn test_subl_get_next_word_pos() {\n        let s = String::from(\"   aaa   ((()))bbb   ((()))   \");\n        let indices = [(0, 3), (1, 3), (3, 9), (9, 15), (15, 21), (21, 30)];\n        for (i_src, i_dest) in indices {\n            assert_eq!(SUBL_WORD_JUMPER.get_next_word_pos(&s, i_src), i_dest);\n        }\n        assert_eq!(SUBL_WORD_JUMPER.get_next_word_pos(\"\", 0), 0);\n    }\n\n    #[test]\n    fn test_subl_get_prev_word_pos() {\n        let s = String::from(\"   aaa   ((()))bbb   ((()))   \");\n        let indices = [(30, 21), (21, 15), (15, 9), (9, 3), (3, 0)];\n        for (i_src, i_dest) in indices {\n            assert_eq!(SUBL_WORD_JUMPER.get_prev_word_pos(&s, i_src), i_dest);\n        }\n        assert_eq!(SUBL_WORD_JUMPER.get_prev_word_pos(\"\", 0), 0);\n    }\n\n    #[test]\n    fn pop() {\n        let mut s = String::from(\"öaöböcödöeöfö\");\n        let mut c = Cursor::from(s.clone());\n        c.end();\n        while !s.is_empty() {\n            let c1 = s.pop();\n            let c2 = c.back();\n            assert_eq!(c1, c2);\n            assert_eq!(s.as_str(), c.substring());\n        }\n        let c1 = s.pop();\n        let c2 = c.back();\n        assert_eq!(c1, c2);\n    }\n\n    #[test]\n    fn back() {\n        let mut c = Cursor::from(String::from(\"öaöböcödöeöfö\"));\n        // move to                                 ^\n        for _ in 0..4 {\n            c.right();\n        }\n        assert_eq!(c.substring(), \"öaöb\");\n        assert_eq!(c.back(), Some('b'));\n        assert_eq!(c.back(), Some('ö'));\n        assert_eq!(c.back(), Some('a'));\n        assert_eq!(c.back(), Some('ö'));\n        assert_eq!(c.back(), None);\n        assert_eq!(c.as_str(), \"öcödöeöfö\");\n    }\n\n    #[test]\n    fn insert() {\n        let mut c = Cursor::from(String::from(\"öaöböcödöeöfö\"));\n        // move to                                 ^\n        for _ in 0..4 {\n            c.right();\n        }\n        assert_eq!(c.substring(), \"öaöb\");\n        c.insert('ö');\n        c.insert('g');\n        c.insert('ö');\n        c.insert('h');\n        assert_eq!(c.substring(), \"öaöbögöh\");\n        assert_eq!(c.as_str(), \"öaöbögöhöcödöeöfö\");\n    }\n}\n"
  },
  {
    "path": "crates/atuin/src/command/client/search/duration.rs",
    "content": "use core::fmt;\nuse std::{ops::ControlFlow, time::Duration};\n\n#[allow(clippy::module_name_repetitions)]\npub fn format_duration_into(dur: Duration, f: &mut fmt::Formatter<'_>) -> fmt::Result {\n    fn item(unit: &'static str, value: u64) -> ControlFlow<(&'static str, u64)> {\n        if value > 0 {\n            ControlFlow::Break((unit, value))\n        } else {\n            ControlFlow::Continue(())\n        }\n    }\n\n    // impl taken and modified from\n    // https://github.com/tailhook/humantime/blob/master/src/duration.rs#L295-L331\n    // Copyright (c) 2016 The humantime Developers\n    fn fmt(f: Duration) -> ControlFlow<(&'static str, u64), ()> {\n        let secs = f.as_secs();\n        let nanos = f.subsec_nanos();\n\n        let years = secs / 31_557_600; // 365.25d\n        let year_days = secs % 31_557_600;\n        let months = year_days / 2_630_016; // 30.44d\n        let month_days = year_days % 2_630_016;\n        let days = month_days / 86400;\n        let day_secs = month_days % 86400;\n        let hours = day_secs / 3600;\n        let minutes = day_secs % 3600 / 60;\n        let seconds = day_secs % 60;\n\n        let millis = nanos / 1_000_000;\n        let micros = nanos / 1_000;\n\n        // a difference from our impl than the original is that\n        // we only care about the most-significant segment of the duration.\n        // If the item call returns `Break`, then the `?` will early-return.\n        // This allows for a very consise impl\n        item(\"y\", years)?;\n        item(\"mo\", months)?;\n        item(\"d\", days)?;\n        item(\"h\", hours)?;\n        item(\"m\", minutes)?;\n        item(\"s\", seconds)?;\n        item(\"ms\", u64::from(millis))?;\n        item(\"us\", u64::from(micros))?;\n        item(\"ns\", u64::from(nanos))?;\n        ControlFlow::Continue(())\n    }\n\n    match fmt(dur) {\n        ControlFlow::Break((unit, value)) => write!(f, \"{value}{unit}\"),\n        ControlFlow::Continue(()) => write!(f, \"0s\"),\n    }\n}\n\n#[allow(clippy::module_name_repetitions)]\npub fn format_duration(f: Duration) -> String {\n    struct F(Duration);\n    impl fmt::Display for F {\n        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {\n            format_duration_into(self.0, f)\n        }\n    }\n    F(f).to_string()\n}\n"
  },
  {
    "path": "crates/atuin/src/command/client/search/engines/daemon.rs",
    "content": "use async_trait::async_trait;\nuse atuin_client::{\n    database::{Database, OptFilters},\n    history::History,\n    settings::{SearchMode, Settings},\n};\nuse atuin_daemon::client::SearchClient;\nuse atuin_nucleo_matcher::{\n    Config, Matcher, Utf32Str,\n    pattern::{CaseMatching, Normalization, Pattern},\n};\nuse eyre::Result;\nuse tracing::{Level, debug, instrument, span};\nuse uuid::Uuid;\n\nuse super::{SearchEngine, SearchState};\n\npub struct Search {\n    client: Option<SearchClient>,\n    query_id: u64,\n    socket_path: String,\n    #[cfg(not(unix))]\n    tcp_port: u64,\n}\n\nimpl Search {\n    pub fn new(settings: &Settings) -> Self {\n        Search {\n            client: None,\n            query_id: 0,\n            socket_path: settings.daemon.socket_path.clone(),\n            #[cfg(not(unix))]\n            tcp_port: settings.daemon.tcp_port,\n        }\n    }\n\n    #[instrument(skip_all, level = Level::TRACE, name = \"get_daemon_client\")]\n    async fn get_client(&mut self) -> Result<&mut SearchClient> {\n        if self.client.is_none() {\n            #[cfg(unix)]\n            let client = SearchClient::new(self.socket_path.clone()).await?;\n\n            #[cfg(not(unix))]\n            let client = SearchClient::new(self.tcp_port).await?;\n\n            self.client = Some(client);\n        }\n        Ok(self.client.as_mut().unwrap())\n    }\n\n    fn next_query_id(&mut self) -> u64 {\n        self.query_id += 1;\n        self.query_id\n    }\n\n    /// Check if query contains regex pattern (r/.../)\n    /// Nucleo doesn't support regex, so we fall back to database search\n    fn contains_regex_pattern(query: &str) -> bool {\n        query.starts_with(\"r/\") || query.contains(\" r/\")\n    }\n\n    #[instrument(skip_all, level = Level::TRACE, name = \"daemon_db_fallback\")]\n    async fn fallback_to_db_search(\n        &self,\n        state: &SearchState,\n        db: &dyn Database,\n    ) -> Result<Vec<History>> {\n        let results = db\n            .search(\n                SearchMode::FullText,\n                state.filter_mode,\n                &state.context,\n                state.input.as_str(),\n                OptFilters {\n                    limit: Some(200),\n                    ..Default::default()\n                },\n            )\n            .await\n            .map_or(Vec::new(), |r| r.into_iter().collect());\n        Ok(results)\n    }\n\n    #[instrument(skip_all, level = Level::TRACE, name = \"hydrate_from_db\", fields(count = ids.len()))]\n    async fn hydrate_from_db(&self, db: &dyn Database, ids: &[String]) -> Result<Vec<History>> {\n        let placeholders: Vec<String> = ids.iter().map(|id| format!(\"'{id}'\")).collect();\n        let sql_query = format!(\n            \"SELECT * FROM history WHERE id IN ({}) ORDER BY timestamp DESC\",\n            placeholders.join(\",\")\n        );\n        Ok(db.query_history(&sql_query).await?)\n    }\n}\n\n#[async_trait]\nimpl SearchEngine for Search {\n    #[instrument(skip_all, level = Level::TRACE, name = \"daemon_search\", fields(query = %state.input.as_str()))]\n    async fn full_query(\n        &mut self,\n        state: &SearchState,\n        db: &mut dyn Database,\n    ) -> Result<Vec<History>> {\n        let query = state.input.as_str().to_string();\n\n        // Fall back to database for regex queries (Nucleo doesn't support regex)\n        if Self::contains_regex_pattern(&query) {\n            debug!(query = %query, \"[daemon-client] regex detected, falling back to db\");\n            return self.fallback_to_db_search(state, db).await;\n        }\n\n        let query_id = self.next_query_id();\n\n        let span =\n            span!(Level::TRACE, \"daemon_search.req_resp\", query = %query, query_id = query_id);\n\n        let client = self.get_client().await?;\n\n        let _span = span.enter();\n        let mut stream = client\n            .search(\n                query.clone(),\n                query_id,\n                state.filter_mode,\n                Some(state.context.clone()),\n            )\n            .await?;\n\n        let mut ids = Vec::with_capacity(200);\n        span!(Level::TRACE, \"daemon_search.resp\")\n            .in_scope(async || {\n                while let Ok(Some(response)) = stream.message().await {\n                    let span2 = span!(\n                        Level::TRACE,\n                        \"daemon_search.resp.item\",\n                        query_id = response.query_id\n                    );\n                    let _span2 = span2.enter();\n                    // Only process if the query_id matches (prevents stale responses)\n                    if response.query_id == query_id {\n                        let uuids = response\n                            .ids\n                            .iter()\n                            .map(|id| {\n                                let bytes: [u8; 16] =\n                                    id.as_slice().try_into().expect(\"id should be 16 bytes\");\n                                Uuid::from_bytes(bytes).as_simple().to_string()\n                            })\n                            .collect::<Vec<_>>();\n                        ids.extend(uuids);\n                    }\n                    drop(_span2);\n                    drop(span2);\n                }\n            })\n            .await;\n        drop(_span);\n        drop(span);\n\n        if ids.is_empty() {\n            debug!(query = %query, results = 0, \"[daemon-client] empty results\");\n            return Ok(Vec::new());\n        }\n\n        // // Hydrate from local database\n        let results = self.hydrate_from_db(db, &ids).await?;\n\n        // // Reorder results to match the order from the daemon (which is ranked by relevance)\n        let ordered_results = span!(Level::TRACE, \"reorder_results\").in_scope(|| {\n            let mut ordered_results = Vec::with_capacity(results.len());\n            for id in &ids {\n                if let Some(history) = results.iter().find(|h| h.id.0 == *id) {\n                    ordered_results.push(history.clone());\n                }\n            }\n            ordered_results\n        });\n\n        debug!(\n            query = %query,\n            results = results.len(),\n            \"[daemon-client]\"\n        );\n\n        Ok(ordered_results)\n    }\n\n    #[instrument(skip_all, level = Level::TRACE, name = \"daemon_highlight\")]\n    fn get_highlight_indices(&self, command: &str, search_input: &str) -> Vec<usize> {\n        // Use fulltext highlighting for regex queries\n        if Self::contains_regex_pattern(search_input) {\n            return super::db::get_highlight_indices_fulltext(command, search_input);\n        }\n\n        let mut matcher = Matcher::new(Config::DEFAULT);\n        let pattern = Pattern::parse(search_input, CaseMatching::Smart, Normalization::Smart);\n\n        let mut indices: Vec<u32> = Vec::new();\n        let mut haystack_buf = Vec::new();\n\n        let haystack = Utf32Str::new(command, &mut haystack_buf);\n        pattern.indices(haystack, &mut matcher, &mut indices);\n\n        // Convert u32 indices to usize\n        indices.into_iter().map(|i| i as usize).collect()\n    }\n}\n"
  },
  {
    "path": "crates/atuin/src/command/client/search/engines/db.rs",
    "content": "use super::{SearchEngine, SearchState};\nuse async_trait::async_trait;\nuse atuin_client::{\n    database::Database,\n    database::OptFilters,\n    database::{QueryToken, QueryTokenizer},\n    history::History,\n    settings::SearchMode,\n};\nuse eyre::Result;\nuse norm::Metric;\nuse norm::fzf::{FzfParser, FzfV2};\nuse std::ops::Range;\nuse tracing::{Level, instrument};\n\npub struct Search(pub SearchMode);\n\n#[async_trait]\nimpl SearchEngine for Search {\n    #[instrument(skip_all, level = Level::TRACE, name = \"db_search\", fields(mode = ?self.0, query = %state.input.as_str()))]\n    async fn full_query(\n        &mut self,\n        state: &SearchState,\n        db: &mut dyn Database,\n    ) -> Result<Vec<History>> {\n        let results = db\n            .search(\n                self.0,\n                state.filter_mode,\n                &state.context,\n                state.input.as_str(),\n                OptFilters {\n                    limit: Some(200),\n                    ..Default::default()\n                },\n            )\n            .await\n            // ignore errors as it may be caused by incomplete regex\n            .map_or(Vec::new(), |r| r.into_iter().collect());\n        Ok(results)\n    }\n\n    #[instrument(skip_all, level = Level::TRACE, name = \"db_highlight\")]\n    fn get_highlight_indices(&self, command: &str, search_input: &str) -> Vec<usize> {\n        if self.0 == SearchMode::Prefix {\n            return vec![];\n        } else if self.0 == SearchMode::FullText {\n            return get_highlight_indices_fulltext(command, search_input);\n        }\n        let mut fzf = FzfV2::new();\n        let mut parser = FzfParser::new();\n        let query = parser.parse(search_input);\n        let mut ranges: Vec<Range<usize>> = Vec::new();\n        let _ = fzf.distance_and_ranges(query, command, &mut ranges);\n\n        // convert ranges to all indices\n        ranges.into_iter().flatten().collect()\n    }\n}\n\n#[instrument(skip_all, level = Level::TRACE, name = \"db_highlight_fulltext\")]\npub fn get_highlight_indices_fulltext(command: &str, search_input: &str) -> Vec<usize> {\n    let mut ranges = vec![];\n    let lower_command = command.to_ascii_lowercase();\n\n    for token in QueryTokenizer::new(search_input) {\n        let matchee = if token.has_uppercase() {\n            command\n        } else {\n            &lower_command\n        };\n\n        if token.is_inverse() {\n            continue;\n        }\n\n        match token {\n            QueryToken::Or => {}\n            QueryToken::Regex(r) => {\n                if let Ok(re) = regex::Regex::new(r) {\n                    for m in re.find_iter(command) {\n                        ranges.push(m.range());\n                    }\n                }\n            }\n            QueryToken::MatchStart(term, _) => {\n                if matchee.starts_with(term) {\n                    ranges.push(0..term.len());\n                }\n            }\n            QueryToken::MatchEnd(term, _) => {\n                if matchee.ends_with(term) {\n                    let l = matchee.len();\n                    ranges.push((l - term.len())..l);\n                }\n            }\n            QueryToken::Match(term, _) | QueryToken::MatchFull(term, _) => {\n                for (idx, m) in matchee.match_indices(term) {\n                    ranges.push(idx..(idx + m.len()));\n                }\n            }\n        }\n    }\n\n    let mut ret: Vec<_> = ranges.into_iter().flatten().collect();\n    ret.sort_unstable();\n    ret.dedup();\n    ret\n}\n"
  },
  {
    "path": "crates/atuin/src/command/client/search/engines/skim.rs",
    "content": "use std::path::Path;\n\nuse async_trait::async_trait;\nuse atuin_client::{database::Database, history::History, settings::FilterMode};\nuse eyre::Result;\nuse fuzzy_matcher::{FuzzyMatcher, skim::SkimMatcherV2};\nuse itertools::Itertools;\nuse time::OffsetDateTime;\nuse tokio::task::yield_now;\nuse tracing::{Level, instrument, warn};\nuse uuid;\n\nuse super::{SearchEngine, SearchState};\n\npub struct Search {\n    all_history: Vec<(History, i32)>,\n    engine: SkimMatcherV2,\n}\n\nimpl Search {\n    pub fn new() -> Self {\n        Search {\n            all_history: vec![],\n            engine: SkimMatcherV2::default(),\n        }\n    }\n}\n\n#[async_trait]\nimpl SearchEngine for Search {\n    #[instrument(skip_all, level = Level::TRACE, name = \"skim_search\", fields(query = %state.input.as_str()))]\n    async fn full_query(\n        &mut self,\n        state: &SearchState,\n        db: &mut dyn Database,\n    ) -> Result<Vec<History>> {\n        if self.all_history.is_empty() {\n            self.all_history = load_all_history(db).await;\n        }\n\n        Ok(fuzzy_search(&self.engine, state, &self.all_history).await)\n    }\n\n    #[instrument(skip_all, level = Level::TRACE, name = \"skim_highlight\")]\n    fn get_highlight_indices(&self, command: &str, search_input: &str) -> Vec<usize> {\n        let (_, indices) = self\n            .engine\n            .fuzzy_indices(command, search_input)\n            .unwrap_or_default();\n        indices\n    }\n}\n\n#[instrument(skip_all, level = Level::TRACE, name = \"load_all_history\")]\nasync fn load_all_history(db: &dyn Database) -> Vec<(History, i32)> {\n    db.all_with_count().await.unwrap()\n}\n\n#[allow(clippy::too_many_lines)]\n#[instrument(skip_all, level = Level::TRACE, name = \"fuzzy_match\", fields(history_count = all_history.len()))]\nasync fn fuzzy_search(\n    engine: &SkimMatcherV2,\n    state: &SearchState,\n    all_history: &[(History, i32)],\n) -> Vec<History> {\n    let mut set = Vec::with_capacity(200);\n    let mut ranks = Vec::with_capacity(200);\n    let query = state.input.as_str();\n    let now = OffsetDateTime::now_utc();\n\n    for (i, (history, count)) in all_history.iter().enumerate() {\n        if i % 256 == 0 {\n            yield_now().await;\n        }\n        let context = &state.context;\n        let git_root = context\n            .git_root\n            .as_ref()\n            .and_then(|git_root| git_root.to_str())\n            .unwrap_or(&context.cwd);\n        match state.filter_mode {\n            FilterMode::Global => {}\n            // we aggregate host by ',' separating them\n            FilterMode::Host\n                if history\n                    .hostname\n                    .split(',')\n                    .contains(&context.hostname.as_str()) => {}\n            // we aggregate session by concattenating them.\n            // sessions are 32 byte simple uuid formats\n            FilterMode::Session\n                if history\n                    .session\n                    .as_bytes()\n                    .chunks(32)\n                    .contains(&context.session.as_bytes()) => {}\n            // SessionPreload: include current session + global history from before session start\n            FilterMode::SessionPreload => {\n                let is_current_session = {\n                    history\n                        .session\n                        .as_bytes()\n                        .chunks(32)\n                        .any(|chunk| chunk == context.session.as_bytes())\n                };\n\n                if !is_current_session {\n                    let Ok(uuid) = uuid::Uuid::parse_str(&context.session) else {\n                        warn!(\"failed to parse session id '{}'\", context.session);\n                        continue;\n                    };\n                    let Some(timestamp) = uuid.get_timestamp() else {\n                        warn!(\n                            \"failed to get timestamp from uuid '{}'\",\n                            uuid.as_hyphenated()\n                        );\n                        continue;\n                    };\n                    let (seconds, nanos) = timestamp.to_unix();\n                    let Ok(session_start) = time::OffsetDateTime::from_unix_timestamp_nanos(\n                        i128::from(seconds) * 1_000_000_000 + i128::from(nanos),\n                    ) else {\n                        warn!(\n                            \"failed to create OffsetDateTime from second: {seconds}, nanosecond: {nanos}\"\n                        );\n                        continue;\n                    };\n\n                    if history.timestamp >= session_start {\n                        continue;\n                    }\n                }\n            }\n            // we aggregate directory by ':' separating them\n            FilterMode::Directory if history.cwd.split(':').contains(&context.cwd.as_str()) => {}\n            FilterMode::Workspace if history.cwd.split(':').contains(&git_root) => {}\n            _ => continue,\n        }\n        #[allow(clippy::cast_lossless, clippy::cast_precision_loss)]\n        if let Some((score, indices)) = engine.fuzzy_indices(&history.command, query) {\n            let begin = indices.first().copied().unwrap_or_default();\n\n            let mut duration = (now - history.timestamp).as_seconds_f64().log2();\n            if !duration.is_finite() || duration <= 1.0 {\n                duration = 1.0;\n            }\n            // these + X.0 just make the log result a bit smoother.\n            // log is very spiky towards 1-4, but I want a gradual decay.\n            // eg:\n            // log2(4) = 2, log2(5) = 2.3 (16% increase)\n            // log2(8) = 3, log2(9) = 3.16 (5% increase)\n            // log2(16) = 4, log2(17) = 4.08 (2% increase)\n            let count = (*count as f64 + 8.0).log2();\n            let begin = (begin as f64 + 16.0).log2();\n            let path = path_dist(history.cwd.as_ref(), state.context.cwd.as_ref());\n            let path = (path as f64 + 8.0).log2();\n\n            // reduce longer durations, raise higher counts, raise matches close to the start\n            let score = (-score as f64) * count / path / duration / begin;\n\n            'insert: {\n                // algorithm:\n                // 1. find either the position that this command ranks\n                // 2. find the same command positioned better than our rank.\n                for i in 0..set.len() {\n                    // do we out score the current position?\n                    if ranks[i] > score {\n                        ranks.insert(i, score);\n                        set.insert(i, history.clone());\n                        let mut j = i + 1;\n                        while j < set.len() {\n                            // remove duplicates that have a worse score\n                            if set[j].command == history.command {\n                                ranks.remove(j);\n                                set.remove(j);\n\n                                // break this while loop because there won't be any other\n                                // duplicates.\n                                break;\n                            }\n                            j += 1;\n                        }\n\n                        // keep it limited\n                        if ranks.len() > 200 {\n                            ranks.pop();\n                            set.pop();\n                        }\n\n                        break 'insert;\n                    }\n                    // don't continue if this command has a better score already\n                    if set[i].command == history.command {\n                        break 'insert;\n                    }\n                }\n\n                if set.len() < 200 {\n                    ranks.push(score);\n                    set.push(history.clone());\n                }\n            }\n        }\n    }\n\n    set\n}\n\nfn path_dist(a: &Path, b: &Path) -> usize {\n    let mut a: Vec<_> = a.components().collect();\n    let b: Vec<_> = b.components().collect();\n\n    let mut dist = 0;\n\n    // pop a until there's a common ancestor\n    while !b.starts_with(&a) {\n        dist += 1;\n        a.pop();\n    }\n\n    b.len() - a.len() + dist\n}\n"
  },
  {
    "path": "crates/atuin/src/command/client/search/engines.rs",
    "content": "use async_trait::async_trait;\nuse atuin_client::{\n    database::{Context, Database},\n    history::{History, HistoryId},\n    settings::{FilterMode, SearchMode, Settings},\n};\nuse eyre::Result;\n\nuse super::cursor::Cursor;\n\n#[cfg(feature = \"daemon\")]\npub mod daemon;\npub mod db;\npub mod skim;\n\n#[allow(unused)] // settings is only used if daemon feature is enabled\npub fn engine(search_mode: SearchMode, settings: &Settings) -> Box<dyn SearchEngine> {\n    match search_mode {\n        SearchMode::Skim => Box::new(skim::Search::new()) as Box<_>,\n        #[cfg(feature = \"daemon\")]\n        SearchMode::DaemonFuzzy => Box::new(daemon::Search::new(settings)) as Box<_>,\n        #[cfg(not(feature = \"daemon\"))]\n        SearchMode::DaemonFuzzy => {\n            // Fall back to fuzzy mode if daemon feature is not enabled\n            Box::new(db::Search(SearchMode::Fuzzy)) as Box<_>\n        }\n        mode => Box::new(db::Search(mode)) as Box<_>,\n    }\n}\n\npub struct SearchState {\n    pub input: Cursor,\n    pub filter_mode: FilterMode,\n    pub context: Context,\n    pub custom_context: Option<HistoryId>,\n}\n\nimpl SearchState {\n    pub(crate) fn rotate_filter_mode(&mut self, settings: &Settings, offset: isize) {\n        let mut i = settings\n            .search\n            .filters\n            .iter()\n            .position(|&m| m == self.filter_mode)\n            .unwrap_or_default();\n        for _ in 0..settings.search.filters.len() {\n            i = (i.wrapping_add_signed(offset)) % settings.search.filters.len();\n            let mode = settings.search.filters[i];\n            if self.filter_mode_available(mode, settings) {\n                self.filter_mode = mode;\n                break;\n            }\n        }\n    }\n\n    fn filter_mode_available(&self, mode: FilterMode, settings: &Settings) -> bool {\n        match mode {\n            FilterMode::Global | FilterMode::SessionPreload => self.custom_context.is_none(),\n            FilterMode::Workspace => settings.workspaces && self.context.git_root.is_some(),\n            _ => true,\n        }\n    }\n}\n\n#[async_trait]\npub trait SearchEngine: Send + Sync + 'static {\n    async fn full_query(\n        &mut self,\n        state: &SearchState,\n        db: &mut dyn Database,\n    ) -> Result<Vec<History>>;\n\n    async fn query(&mut self, state: &SearchState, db: &mut dyn Database) -> Result<Vec<History>> {\n        if state.input.as_str().is_empty() {\n            Ok(db\n                .list(&[state.filter_mode], &state.context, Some(200), true, false)\n                .await?\n                .into_iter()\n                .collect::<Vec<_>>())\n        } else {\n            self.full_query(state, db).await\n        }\n    }\n    fn get_highlight_indices(&self, command: &str, search_input: &str) -> Vec<usize>;\n}\n"
  },
  {
    "path": "crates/atuin/src/command/client/search/history_list.rs",
    "content": "use std::time::Duration;\n\nuse super::duration::format_duration;\nuse super::engines::SearchEngine;\nuse atuin_client::{\n    history::History,\n    settings::{UiColumn, UiColumnType},\n    theme::{Meaning, Theme},\n};\nuse atuin_common::utils::Escapable as _;\nuse itertools::Itertools;\nuse ratatui::{\n    backend::FromCrossterm,\n    buffer::Buffer,\n    crossterm::style,\n    layout::Rect,\n    style::{Modifier, Style},\n    widgets::{Block, StatefulWidget, Widget},\n};\nuse time::OffsetDateTime;\n\npub struct HistoryHighlighter<'a> {\n    pub engine: &'a dyn SearchEngine,\n    pub search_input: &'a str,\n}\n\nimpl HistoryHighlighter<'_> {\n    pub fn get_highlight_indices(&self, command: &str) -> Vec<usize> {\n        self.engine\n            .get_highlight_indices(command, self.search_input)\n    }\n}\n\npub struct HistoryList<'a> {\n    history: &'a [History],\n    block: Option<Block<'a>>,\n    inverted: bool,\n    /// Apply an alternative highlighting to the selected row\n    alternate_highlight: bool,\n    now: &'a dyn Fn() -> OffsetDateTime,\n    indicator: &'a str,\n    theme: &'a Theme,\n    history_highlighter: HistoryHighlighter<'a>,\n    show_numeric_shortcuts: bool,\n    /// Columns to display (in order, after the indicator)\n    columns: &'a [UiColumn],\n}\n\n#[derive(Default)]\npub struct ListState {\n    offset: usize,\n    selected: usize,\n    max_entries: usize,\n}\n\nimpl ListState {\n    pub fn selected(&self) -> usize {\n        self.selected\n    }\n\n    pub fn max_entries(&self) -> usize {\n        self.max_entries\n    }\n\n    pub fn offset(&self) -> usize {\n        self.offset\n    }\n\n    pub fn select(&mut self, index: usize) {\n        self.selected = index;\n    }\n}\n\nimpl StatefulWidget for HistoryList<'_> {\n    type State = ListState;\n\n    fn render(mut self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {\n        let list_area = self.block.take().map_or(area, |b| {\n            let inner_area = b.inner(area);\n            b.render(area, buf);\n            inner_area\n        });\n\n        if list_area.width < 1 || list_area.height < 1 || self.history.is_empty() {\n            return;\n        }\n        let list_height = list_area.height as usize;\n\n        let (start, end) = self.get_items_bounds(state.selected, state.offset, list_height);\n        state.offset = start;\n        state.max_entries = end - start;\n\n        let mut s = DrawState {\n            buf,\n            list_area,\n            x: 0,\n            y: 0,\n            state,\n            inverted: self.inverted,\n            alternate_highlight: self.alternate_highlight,\n            now: &self.now,\n            indicator: self.indicator,\n            theme: self.theme,\n            history_highlighter: self.history_highlighter,\n            show_numeric_shortcuts: self.show_numeric_shortcuts,\n            columns: self.columns,\n        };\n\n        for item in self.history.iter().skip(state.offset).take(end - start) {\n            s.render_row(item);\n\n            // reset line\n            s.y += 1;\n            s.x = 0;\n        }\n    }\n}\n\nimpl<'a> HistoryList<'a> {\n    #[allow(clippy::too_many_arguments)]\n    pub fn new(\n        history: &'a [History],\n        inverted: bool,\n        alternate_highlight: bool,\n        now: &'a dyn Fn() -> OffsetDateTime,\n        indicator: &'a str,\n        theme: &'a Theme,\n        history_highlighter: HistoryHighlighter<'a>,\n        show_numeric_shortcuts: bool,\n        columns: &'a [UiColumn],\n    ) -> Self {\n        Self {\n            history,\n            block: None,\n            inverted,\n            alternate_highlight,\n            now,\n            indicator,\n            theme,\n            history_highlighter,\n            show_numeric_shortcuts,\n            columns,\n        }\n    }\n\n    pub fn block(mut self, block: Block<'a>) -> Self {\n        self.block = Some(block);\n        self\n    }\n\n    fn get_items_bounds(&self, selected: usize, offset: usize, height: usize) -> (usize, usize) {\n        let offset = offset.min(self.history.len().saturating_sub(1));\n\n        let max_scroll_space = height.min(10).min(self.history.len() - selected);\n        if offset + height < selected + max_scroll_space {\n            let end = selected + max_scroll_space;\n            (end - height, end)\n        } else if selected < offset {\n            (selected, selected + height)\n        } else {\n            (offset, offset + height)\n        }\n    }\n}\n\nstruct DrawState<'a> {\n    buf: &'a mut Buffer,\n    list_area: Rect,\n    x: u16,\n    y: u16,\n    state: &'a ListState,\n    inverted: bool,\n    alternate_highlight: bool,\n    now: &'a dyn Fn() -> OffsetDateTime,\n    indicator: &'a str,\n    theme: &'a Theme,\n    history_highlighter: HistoryHighlighter<'a>,\n    show_numeric_shortcuts: bool,\n    columns: &'a [UiColumn],\n}\n\n// these encode the slices of `\" > \"`, `\" {n} \"`, or `\"   \"` in a compact form.\n// Yes, this is a hack, but it makes me feel happy\nstatic SLICES: &str = \" > 1 2 3 4 5 6 7 8 9   \";\n\nimpl DrawState<'_> {\n    /// Render a complete row for a history item based on configured columns.\n    fn render_row(&mut self, h: &History) {\n        // Always render the indicator first (width 3)\n        self.index();\n\n        // Calculate the width for the expanding column\n        // Fixed columns use their configured width + 1 (trailing space)\n        let indicator_width: u16 = 3;\n        let fixed_width: u16 = self\n            .columns\n            .iter()\n            .filter(|c| !c.expand)\n            .map(|c| c.width + 1)\n            .sum();\n        let expand_width = self\n            .list_area\n            .width\n            .saturating_sub(indicator_width + fixed_width);\n\n        let style = self.theme.as_style(Meaning::Base);\n        // Render each configured column\n        for (idx, column) in self.columns.iter().enumerate() {\n            if idx != 0 {\n                self.draw(\" \", Style::from_crossterm(style));\n            }\n            let width = if column.expand {\n                expand_width\n            } else {\n                column.width\n            };\n            match column.column_type {\n                UiColumnType::Duration => self.duration(h, width),\n                UiColumnType::Time => self.time(h, width),\n                UiColumnType::Datetime => self.datetime(h, width),\n                UiColumnType::Directory => self.directory(h, width),\n                UiColumnType::Host => self.host(h, width),\n                UiColumnType::User => self.user(h, width),\n                UiColumnType::Exit => self.exit_code(h, width),\n                UiColumnType::Command => self.command(h),\n            }\n        }\n    }\n\n    fn index(&mut self) {\n        if !self.show_numeric_shortcuts {\n            let i = self.y as usize + self.state.offset;\n            let is_selected = i == self.state.selected();\n            let prompt: &str = if is_selected { self.indicator } else { \"   \" };\n            self.draw(prompt, Style::default());\n            return;\n        }\n\n        // these encode the slices of `\" > \"`, `\" {n} \"`, or `\"   \"` in a compact form.\n        // Yes, this is a hack, but it makes me feel happy\n\n        let i = self.y as usize + self.state.offset;\n        let i = i.checked_sub(self.state.selected);\n        let i = i.unwrap_or(10).min(10) * 2;\n        let prompt: &str = if i == 0 {\n            self.indicator\n        } else {\n            &SLICES[i..i + 3]\n        };\n        self.draw(prompt, Style::default());\n    }\n\n    fn duration(&mut self, h: &History, width: u16) {\n        let style = self.theme.as_style(if h.success() {\n            Meaning::AlertInfo\n        } else {\n            Meaning::AlertError\n        });\n        let duration = Duration::from_nanos(u64::try_from(h.duration).unwrap_or(0));\n        let formatted = format_duration(duration);\n        let w = width as usize;\n        // Right-align duration within its column width, plus trailing space\n        let display = format!(\"{formatted:>w$}\");\n        self.draw(&display, Style::from_crossterm(style));\n    }\n\n    fn time(&mut self, h: &History, width: u16) {\n        let style = self.theme.as_style(Meaning::Guidance);\n\n        // Account for the chance that h.timestamp is \"in the future\"\n        // This would mean that \"since\" is negative, and the unwrap here\n        // would fail.\n        // If the timestamp would otherwise be in the future, display\n        // the time since as 0.\n        let since = (self.now)() - h.timestamp;\n        let time = format_duration(since.try_into().unwrap_or_default());\n\n        // Format as \"Xs ago\" right-aligned within column width\n        let w = width as usize;\n        let time_str = format!(\"{time} ago\");\n\n        let display = format!(\"{time_str:>w$}\");\n        self.draw(&display, Style::from_crossterm(style));\n    }\n\n    fn command(&mut self, h: &History) {\n        let mut style = self.theme.as_style(Meaning::Base);\n        let mut row_highlighted = false;\n        if !self.alternate_highlight && (self.y as usize + self.state.offset == self.state.selected)\n        {\n            row_highlighted = true;\n            // if not applying alternative highlighting to the whole row, color the command\n            style = self.theme.as_style(Meaning::AlertError);\n            style.attributes.set(style::Attribute::Bold);\n        }\n\n        let highlight_indices = self.history_highlighter.get_highlight_indices(\n            h.command\n                .escape_control()\n                .split_ascii_whitespace()\n                .join(\" \")\n                .as_str(),\n        );\n\n        let mut pos = 0;\n        for section in h.command.escape_control().split_ascii_whitespace() {\n            if pos != 0 {\n                self.draw(\" \", Style::from_crossterm(style));\n            }\n            for ch in section.chars() {\n                if self.x > self.list_area.width {\n                    // Avoid attempting to draw a command section beyond the width\n                    // of the list\n                    return;\n                }\n                let mut style = style;\n                if highlight_indices.contains(&pos) {\n                    if row_highlighted {\n                        // if the row is highlighted bold is not enough as the whole row is bold\n                        // change the color too\n                        style = self.theme.as_style(Meaning::AlertWarn);\n                    }\n                    style.attributes.set(style::Attribute::Bold);\n                }\n                let s = ch.to_string();\n                self.draw(&s, Style::from_crossterm(style));\n                pos += s.len();\n            }\n            pos += 1;\n        }\n    }\n\n    /// Render the absolute datetime column (e.g., \"2025-01-22 14:35\")\n    fn datetime(&mut self, h: &History, width: u16) {\n        let style = self.theme.as_style(Meaning::Annotation);\n        // Format: YYYY-MM-DD HH:MM\n        let formatted = h\n            .timestamp\n            .format(\n                &time::format_description::parse(\"[year]-[month]-[day] [hour]:[minute]\")\n                    .expect(\"valid format\"),\n            )\n            .unwrap_or_else(|_| \"????-??-?? ??:??\".to_string());\n        let w = width as usize;\n        let display = format!(\"{formatted:w$}\");\n        self.draw(&display, Style::from_crossterm(style));\n    }\n\n    /// Render the directory column (working directory, truncated)\n    fn directory(&mut self, h: &History, width: u16) {\n        let style = self.theme.as_style(Meaning::Annotation);\n        let w = width as usize;\n        let cwd = &h.cwd;\n        let char_count = cwd.chars().count();\n        // Truncate from the left with \"...\" if too long, plus trailing space\n        // Use character count for comparison and skip for UTF-8 safety\n        let display = if char_count > w && w >= 4 {\n            let truncated: String = cwd.chars().skip(char_count - (w - 3)).collect();\n            format!(\"...{truncated}\")\n        } else {\n            format!(\"{cwd:w$}\")\n        };\n        self.draw(&display, Style::from_crossterm(style));\n    }\n\n    /// Render the host column (just the hostname)\n    fn host(&mut self, h: &History, width: u16) {\n        let style = self.theme.as_style(Meaning::Annotation);\n        let w = width as usize;\n        // Database stores hostname as \"hostname:username\"\n        let host = h.hostname.split(':').next().unwrap_or(&h.hostname);\n        let char_count = host.chars().count();\n        // Use character count for comparison and take for UTF-8 safety\n        let display = if char_count > w && w >= 4 {\n            let truncated: String = host.chars().take(w.saturating_sub(4)).collect();\n            format!(\"{truncated}...\")\n        } else {\n            format!(\"{host:w$}\")\n        };\n        self.draw(&display, Style::from_crossterm(style));\n    }\n\n    /// Render the user column\n    fn user(&mut self, h: &History, width: u16) {\n        let style = self.theme.as_style(Meaning::Annotation);\n        let w = width as usize;\n        // Database stores hostname as \"hostname:username\"\n        let user = h.hostname.split(':').nth(1).unwrap_or(\"\");\n        let char_count = user.chars().count();\n        // Use character count for comparison and take for UTF-8 safety\n        let display = if char_count > w && w >= 4 {\n            let truncated: String = user.chars().take(w.saturating_sub(4)).collect();\n            format!(\"{truncated}...\")\n        } else {\n            format!(\"{user:w$}\")\n        };\n        self.draw(&display, Style::from_crossterm(style));\n    }\n\n    /// Render the exit code column\n    fn exit_code(&mut self, h: &History, width: u16) {\n        let style = if h.success() {\n            self.theme.as_style(Meaning::AlertInfo)\n        } else {\n            self.theme.as_style(Meaning::AlertError)\n        };\n        let w = width as usize;\n        let display = format!(\"{:>w$}\", h.exit);\n        self.draw(&display, Style::from_crossterm(style));\n    }\n\n    fn draw(&mut self, s: &str, mut style: Style) {\n        let cx = self.list_area.left() + self.x;\n\n        let cy = if self.inverted {\n            self.list_area.top() + self.y\n        } else {\n            self.list_area.bottom() - self.y - 1\n        };\n\n        if self.alternate_highlight && (self.y as usize + self.state.offset == self.state.selected)\n        {\n            style = style.add_modifier(Modifier::REVERSED);\n        }\n\n        let w = (self.list_area.width - self.x) as usize;\n        self.x += self.buf.set_stringn(cx, cy, s, w, style).0 - cx;\n    }\n}\n"
  },
  {
    "path": "crates/atuin/src/command/client/search/inspector.rs",
    "content": "use std::time::Duration;\nuse time::macros::format_description;\n\nuse atuin_client::{\n    history::{History, HistoryStats},\n    settings::{Settings, Timezone},\n};\nuse ratatui::{\n    Frame,\n    backend::FromCrossterm,\n    layout::Rect,\n    prelude::{Constraint, Direction, Layout},\n    style::Style,\n    text::{Span, Text},\n    widgets::{Bar, BarChart, BarGroup, Block, Borders, Padding, Paragraph, Row, Table},\n};\n\nuse super::duration::format_duration;\n\nuse super::super::theme::{Meaning, Theme};\nuse super::interactive::{Compactness, to_compactness};\n\n#[allow(clippy::cast_sign_loss)]\nfn u64_or_zero(num: i64) -> u64 {\n    if num < 0 { 0 } else { num as u64 }\n}\n\npub fn draw_commands(\n    f: &mut Frame<'_>,\n    parent: Rect,\n    history: &History,\n    stats: &HistoryStats,\n    compact: bool,\n    theme: &Theme,\n) {\n    let commands = Layout::default()\n        .direction(if compact {\n            Direction::Vertical\n        } else {\n            Direction::Horizontal\n        })\n        .constraints(if compact {\n            [\n                Constraint::Length(1),\n                Constraint::Length(1),\n                Constraint::Min(0),\n            ]\n        } else {\n            [\n                Constraint::Ratio(1, 4),\n                Constraint::Ratio(1, 2),\n                Constraint::Ratio(1, 4),\n            ]\n        })\n        .split(parent);\n\n    let command = Paragraph::new(Text::from(Span::styled(\n        history.command.clone(),\n        Style::from_crossterm(theme.as_style(Meaning::Important)),\n    )))\n    .block(if compact {\n        Block::new()\n            .borders(Borders::NONE)\n            .style(Style::from_crossterm(theme.as_style(Meaning::Base)))\n    } else {\n        Block::new()\n            .borders(Borders::ALL)\n            .style(Style::from_crossterm(theme.as_style(Meaning::Base)))\n            .title(\"Command\")\n            .padding(Padding::horizontal(1))\n    });\n\n    let previous = Paragraph::new(\n        stats\n            .previous\n            .clone()\n            .map_or_else(|| \"[No previous command]\".to_string(), |prev| prev.command),\n    )\n    .block(if compact {\n        Block::new()\n            .borders(Borders::NONE)\n            .style(Style::from_crossterm(theme.as_style(Meaning::Annotation)))\n    } else {\n        Block::new()\n            .borders(Borders::ALL)\n            .style(Style::from_crossterm(theme.as_style(Meaning::Annotation)))\n            .title(\"Previous command\")\n            .padding(Padding::horizontal(1))\n    });\n\n    // Add [] around blank text, as when this is shown in a list\n    // compacted, it makes it more obviously control text.\n    let next = Paragraph::new(\n        stats\n            .next\n            .clone()\n            .map_or_else(|| \"[No next command]\".to_string(), |next| next.command),\n    )\n    .block(if compact {\n        Block::new()\n            .borders(Borders::NONE)\n            .style(Style::from_crossterm(theme.as_style(Meaning::Annotation)))\n    } else {\n        Block::new()\n            .borders(Borders::ALL)\n            .title(\"Next command\")\n            .padding(Padding::horizontal(1))\n            .style(Style::from_crossterm(theme.as_style(Meaning::Annotation)))\n    });\n\n    f.render_widget(previous, commands[0]);\n    f.render_widget(command, commands[1]);\n    f.render_widget(next, commands[2]);\n}\n\npub fn draw_stats_table(\n    f: &mut Frame<'_>,\n    parent: Rect,\n    history: &History,\n    tz: Timezone,\n    stats: &HistoryStats,\n    theme: &Theme,\n) {\n    let duration = Duration::from_nanos(u64_or_zero(history.duration));\n    let avg_duration = Duration::from_nanos(stats.average_duration);\n    let (host, user) = history.hostname.split_once(':').unwrap_or((\"\", \"\"));\n\n    let rows = [\n        Row::new(vec![\"Host\".to_string(), host.to_string()]),\n        Row::new(vec![\"User\".to_string(), user.to_string()]),\n        Row::new(vec![\n            \"Time\".to_string(),\n            history.timestamp.to_offset(tz.0).to_string(),\n        ]),\n        Row::new(vec![\"Duration\".to_string(), format_duration(duration)]),\n        Row::new(vec![\n            \"Avg duration\".to_string(),\n            format_duration(avg_duration),\n        ]),\n        Row::new(vec![\"Exit\".to_string(), history.exit.to_string()]),\n        Row::new(vec![\"Directory\".to_string(), history.cwd.clone()]),\n        Row::new(vec![\"Session\".to_string(), history.session.clone()]),\n        Row::new(vec![\"Total runs\".to_string(), stats.total.to_string()]),\n    ];\n\n    let widths = [Constraint::Ratio(1, 5), Constraint::Ratio(4, 5)];\n\n    let table = Table::new(rows, widths).column_spacing(1).block(\n        Block::default()\n            .title(\"Command stats\")\n            .borders(Borders::ALL)\n            .style(Style::from_crossterm(theme.as_style(Meaning::Base)))\n            .padding(Padding::vertical(1)),\n    );\n\n    f.render_widget(table, parent);\n}\n\nfn num_to_day(num: &str) -> String {\n    match num {\n        \"0\" => \"Sunday\".to_string(),\n        \"1\" => \"Monday\".to_string(),\n        \"2\" => \"Tuesday\".to_string(),\n        \"3\" => \"Wednesday\".to_string(),\n        \"4\" => \"Thursday\".to_string(),\n        \"5\" => \"Friday\".to_string(),\n        \"6\" => \"Saturday\".to_string(),\n        _ => \"Invalid day\".to_string(),\n    }\n}\n\nfn sort_duration_over_time(durations: &[(String, i64)]) -> Vec<(String, i64)> {\n    let format = format_description!(\"[day]-[month]-[year]\");\n    let output = format_description!(\"[month]/[year repr:last_two]\");\n\n    let mut durations: Vec<(time::Date, i64)> = durations\n        .iter()\n        .map(|d| {\n            (\n                time::Date::parse(d.0.as_str(), &format).expect(\"invalid date string from sqlite\"),\n                d.1,\n            )\n        })\n        .collect();\n\n    durations.sort_by(|a, b| a.0.cmp(&b.0));\n\n    durations\n        .iter()\n        .map(|(date, duration)| {\n            (\n                date.format(output).expect(\"failed to format sqlite date\"),\n                *duration,\n            )\n        })\n        .collect()\n}\n\nfn draw_stats_charts(f: &mut Frame<'_>, parent: Rect, stats: &HistoryStats, theme: &Theme) {\n    let exits: Vec<Bar> = stats\n        .exits\n        .iter()\n        .map(|(exit, count)| {\n            Bar::default()\n                .label(exit.to_string())\n                .value(u64_or_zero(*count))\n        })\n        .collect();\n\n    let exits = BarChart::default()\n        .block(\n            Block::default()\n                .title(\"Exit code distribution\")\n                .style(Style::from_crossterm(theme.as_style(Meaning::Base)))\n                .borders(Borders::ALL),\n        )\n        .bar_width(3)\n        .bar_gap(1)\n        .bar_style(Style::default())\n        .value_style(Style::default())\n        .label_style(Style::default())\n        .data(BarGroup::default().bars(&exits));\n\n    let day_of_week: Vec<Bar> = stats\n        .day_of_week\n        .iter()\n        .map(|(day, count)| {\n            Bar::default()\n                .label(num_to_day(day.as_str()))\n                .value(u64_or_zero(*count))\n        })\n        .collect();\n\n    let day_of_week = BarChart::default()\n        .block(\n            Block::default()\n                .title(\"Runs per day\")\n                .style(Style::from_crossterm(theme.as_style(Meaning::Base)))\n                .borders(Borders::ALL),\n        )\n        .bar_width(3)\n        .bar_gap(1)\n        .bar_style(Style::default())\n        .value_style(Style::default())\n        .label_style(Style::default())\n        .data(BarGroup::default().bars(&day_of_week));\n\n    let duration_over_time = sort_duration_over_time(&stats.duration_over_time);\n    let duration_over_time: Vec<Bar> = duration_over_time\n        .iter()\n        .map(|(date, duration)| {\n            let d = Duration::from_nanos(u64_or_zero(*duration));\n            Bar::default()\n                .label(date.clone())\n                .value(u64_or_zero(*duration))\n                .text_value(format_duration(d))\n        })\n        .collect();\n\n    let duration_over_time = BarChart::default()\n        .block(\n            Block::default()\n                .title(\"Duration over time\")\n                .style(Style::from_crossterm(theme.as_style(Meaning::Base)))\n                .borders(Borders::ALL),\n        )\n        .bar_width(5)\n        .bar_gap(1)\n        .bar_style(Style::default())\n        .value_style(Style::default())\n        .label_style(Style::default())\n        .data(BarGroup::default().bars(&duration_over_time));\n\n    let layout = Layout::default()\n        .direction(Direction::Vertical)\n        .constraints([\n            Constraint::Ratio(1, 3),\n            Constraint::Ratio(1, 3),\n            Constraint::Ratio(1, 3),\n        ])\n        .split(parent);\n\n    f.render_widget(exits, layout[0]);\n    f.render_widget(day_of_week, layout[1]);\n    f.render_widget(duration_over_time, layout[2]);\n}\n\npub fn draw(\n    f: &mut Frame<'_>,\n    chunk: Rect,\n    history: &History,\n    stats: &HistoryStats,\n    settings: &Settings,\n    theme: &Theme,\n    tz: Timezone,\n) {\n    let compactness = to_compactness(f, settings);\n\n    match compactness {\n        Compactness::Ultracompact => draw_ultracompact(f, chunk, history, stats, theme),\n        _ => draw_full(f, chunk, history, stats, theme, tz),\n    }\n}\n\npub fn draw_ultracompact(\n    f: &mut Frame<'_>,\n    chunk: Rect,\n    history: &History,\n    stats: &HistoryStats,\n    theme: &Theme,\n) {\n    draw_commands(f, chunk, history, stats, true, theme);\n}\n\npub fn draw_full(\n    f: &mut Frame<'_>,\n    chunk: Rect,\n    history: &History,\n    stats: &HistoryStats,\n    theme: &Theme,\n    tz: Timezone,\n) {\n    let vert_layout = Layout::default()\n        .direction(Direction::Vertical)\n        .constraints([Constraint::Ratio(1, 5), Constraint::Ratio(4, 5)])\n        .split(chunk);\n\n    let stats_layout = Layout::default()\n        .direction(Direction::Horizontal)\n        .constraints([Constraint::Ratio(1, 3), Constraint::Ratio(2, 3)])\n        .split(vert_layout[1]);\n\n    draw_commands(f, vert_layout[0], history, stats, false, theme);\n    draw_stats_table(f, stats_layout[0], history, tz, stats, theme);\n    draw_stats_charts(f, stats_layout[1], stats, theme);\n}\n\n#[cfg(test)]\nmod tests {\n    use super::draw_ultracompact;\n    use atuin_client::{\n        history::{History, HistoryId, HistoryStats},\n        theme::ThemeManager,\n    };\n    use ratatui::{backend::TestBackend, prelude::*};\n    use time::OffsetDateTime;\n\n    fn mock_history_stats() -> (History, HistoryStats) {\n        let history = History {\n            id: HistoryId::from(\"test1\".to_string()),\n            timestamp: OffsetDateTime::now_utc(),\n            duration: 3,\n            exit: 0,\n            command: \"/bin/cmd\".to_string(),\n            cwd: \"/toot\".to_string(),\n            session: \"sesh1\".to_string(),\n            hostname: \"hostn\".to_string(),\n            author: \"hostn\".to_string(),\n            intent: None,\n            deleted_at: None,\n        };\n        let next = History {\n            id: HistoryId::from(\"test2\".to_string()),\n            timestamp: OffsetDateTime::now_utc(),\n            duration: 2,\n            exit: 0,\n            command: \"/bin/cmd -os\".to_string(),\n            cwd: \"/toot\".to_string(),\n            session: \"sesh1\".to_string(),\n            hostname: \"hostn\".to_string(),\n            author: \"hostn\".to_string(),\n            intent: None,\n            deleted_at: None,\n        };\n        let prev = History {\n            id: HistoryId::from(\"test3\".to_string()),\n            timestamp: OffsetDateTime::now_utc(),\n            duration: 1,\n            exit: 0,\n            command: \"/bin/cmd -a\".to_string(),\n            cwd: \"/toot\".to_string(),\n            session: \"sesh1\".to_string(),\n            hostname: \"hostn\".to_string(),\n            author: \"hostn\".to_string(),\n            intent: None,\n            deleted_at: None,\n        };\n        let stats = HistoryStats {\n            next: Some(next.clone()),\n            previous: Some(prev.clone()),\n            total: 2,\n            average_duration: 3,\n            exits: Vec::new(),\n            day_of_week: Vec::new(),\n            duration_over_time: Vec::new(),\n        };\n        (history, stats)\n    }\n\n    #[test]\n    fn test_output_looks_correct_for_ultracompact() {\n        let backend = TestBackend::new(22, 5);\n        let mut terminal = Terminal::new(backend).expect(\"Could not create terminal\");\n        let chunk = Rect::new(0, 0, 22, 5);\n        let (history, stats) = mock_history_stats();\n        let prev = stats.previous.clone().unwrap();\n        let next = stats.next.clone().unwrap();\n\n        let mut manager = ThemeManager::new(Some(true), Some(\"\".to_string()));\n        let theme = manager.load_theme(\"(none)\", None);\n        let _ = terminal.draw(|f| draw_ultracompact(f, chunk, &history, &stats, &theme));\n        let mut lines = [\"                      \"; 5].map(|l| Line::from(l));\n        for (n, entry) in [prev, history, next].iter().enumerate() {\n            let mut l = lines[n].to_string();\n            l.replace_range(0..entry.command.len(), &entry.command);\n            lines[n] = Line::from(l);\n        }\n\n        terminal.backend().assert_buffer_lines(lines);\n    }\n}\n"
  },
  {
    "path": "crates/atuin/src/command/client/search/interactive.rs",
    "content": "use std::{\n    io::{IsTerminal, Write, stdout},\n    time::Duration,\n};\n\n#[cfg(unix)]\nuse std::io::Read as _;\n\nuse atuin_common::{shell::Shell, utils::Escapable as _};\nuse eyre::Result;\nuse futures_util::FutureExt;\nuse semver::Version;\nuse time::OffsetDateTime;\nuse unicode_width::UnicodeWidthStr;\n\nuse super::{\n    cursor::Cursor,\n    engines::{SearchEngine, SearchState},\n    history_list::{HistoryList, ListState},\n};\nuse atuin_client::{\n    database::{Context, Database, current_context},\n    history::{History, HistoryId, HistoryStats, store::HistoryStore},\n    settings::{\n        CursorStyle, ExitMode, FilterMode, KeymapMode, PreviewStrategy, SearchMode, Settings,\n        UiColumn,\n    },\n};\n\nuse crate::command::client::search::history_list::HistoryHighlighter;\nuse crate::command::client::search::keybindings::KeymapSet;\nuse crate::command::client::theme::{Meaning, Theme};\nuse crate::{VERSION, command::client::search::engines};\n\nuse ratatui::{\n    Frame, Terminal, TerminalOptions, Viewport,\n    backend::{CrosstermBackend, FromCrossterm},\n    crossterm::{\n        cursor::SetCursorStyle,\n        event::{self, Event, KeyEvent, MouseEvent},\n        execute, queue, terminal,\n    },\n    layout::{Alignment, Constraint, Direction, Layout},\n    prelude::*,\n    style::{Modifier, Style},\n    text::{Line, Span, Text},\n    widgets::{Block, BorderType, Borders, Clear, Padding, Paragraph, Tabs},\n};\n\n#[cfg(not(target_os = \"windows\"))]\nuse ratatui::crossterm::event::{\n    KeyboardEnhancementFlags, PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags,\n};\n\nconst TAB_TITLES: [&str; 2] = [\"Search\", \"Inspect\"];\n\npub enum InputAction {\n    Accept(usize),\n    AcceptInspecting,\n    Copy(usize),\n    Delete(usize),\n    ReturnOriginal,\n    ReturnQuery,\n    Continue,\n    Redraw,\n    SwitchContext(Option<usize>),\n}\n\n#[derive(Clone)]\npub struct InspectingState {\n    current: Option<HistoryId>,\n    next: Option<HistoryId>,\n    previous: Option<HistoryId>,\n}\n\nimpl InspectingState {\n    pub fn move_to_previous(&mut self) {\n        let previous = self.previous.clone();\n        self.reset();\n        self.current = previous;\n    }\n\n    pub fn move_to_next(&mut self) {\n        let next = self.next.clone();\n        self.reset();\n        self.current = next;\n    }\n\n    pub fn reset(&mut self) {\n        self.current = None;\n        self.next = None;\n        self.previous = None;\n    }\n}\n\npub fn to_compactness(f: &Frame, settings: &Settings) -> Compactness {\n    if match settings.style {\n        atuin_client::settings::Style::Auto => f.area().height < 14,\n        atuin_client::settings::Style::Compact => true,\n        atuin_client::settings::Style::Full => false,\n    } {\n        if settings.auto_hide_height != 0 && f.area().height <= settings.auto_hide_height {\n            Compactness::Ultracompact\n        } else {\n            Compactness::Compact\n        }\n    } else {\n        Compactness::Full\n    }\n}\n\n#[allow(clippy::struct_field_names)]\n#[allow(clippy::struct_excessive_bools)]\npub struct State {\n    history_count: i64,\n    update_needed: Option<Version>,\n    results_state: ListState,\n    switched_search_mode: bool,\n    search_mode: SearchMode,\n    results_len: usize,\n    accept: bool,\n    keymap_mode: KeymapMode,\n    prefix: bool,\n    current_cursor: Option<CursorStyle>,\n    tab_index: usize,\n    pending_vim_key: Option<char>,\n    original_input_empty: bool,\n\n    pub inspecting_state: InspectingState,\n\n    keymaps: KeymapSet,\n    search: SearchState,\n    engine: Box<dyn SearchEngine>,\n    now: Box<dyn Fn() -> OffsetDateTime + Send>,\n}\n\n#[derive(Clone, Copy)]\npub enum Compactness {\n    Ultracompact,\n    Compact,\n    Full,\n}\n\n#[derive(Clone, Copy)]\nstruct StyleState {\n    compactness: Compactness,\n    invert: bool,\n    inner_width: usize,\n}\n\nimpl State {\n    async fn query_results(\n        &mut self,\n        db: &mut dyn Database,\n        smart_sort: bool,\n    ) -> Result<Vec<History>> {\n        let results = self.engine.query(&self.search, db).await?;\n\n        self.inspecting_state = InspectingState {\n            current: None,\n            next: None,\n            previous: None,\n        };\n        self.results_state.select(0);\n        self.results_len = results.len();\n\n        if smart_sort {\n            Ok(atuin_history::sort::sort(\n                self.search.input.as_str(),\n                results,\n            ))\n        } else {\n            Ok(results)\n        }\n    }\n\n    fn handle_input(&mut self, settings: &Settings, input: &Event) -> InputAction {\n        match input {\n            Event::Key(k) => self.handle_key_input(settings, k),\n            Event::Mouse(m) => self.handle_mouse_input(*m),\n            Event::Paste(d) => self.handle_paste_input(d),\n            _ => InputAction::Continue,\n        }\n    }\n\n    fn handle_mouse_input(&mut self, input: MouseEvent) -> InputAction {\n        match input.kind {\n            event::MouseEventKind::ScrollDown => {\n                self.scroll_down(1);\n            }\n            event::MouseEventKind::ScrollUp => {\n                self.scroll_up(1);\n            }\n            _ => {}\n        }\n        InputAction::Continue\n    }\n\n    fn handle_paste_input(&mut self, input: &str) -> InputAction {\n        for i in input.chars() {\n            self.search.input.insert(i);\n        }\n        InputAction::Continue\n    }\n\n    fn cast_cursor_style(style: CursorStyle) -> SetCursorStyle {\n        match style {\n            CursorStyle::DefaultUserShape => SetCursorStyle::DefaultUserShape,\n            CursorStyle::BlinkingBlock => SetCursorStyle::BlinkingBlock,\n            CursorStyle::SteadyBlock => SetCursorStyle::SteadyBlock,\n            CursorStyle::BlinkingUnderScore => SetCursorStyle::BlinkingUnderScore,\n            CursorStyle::SteadyUnderScore => SetCursorStyle::SteadyUnderScore,\n            CursorStyle::BlinkingBar => SetCursorStyle::BlinkingBar,\n            CursorStyle::SteadyBar => SetCursorStyle::SteadyBar,\n        }\n    }\n\n    fn set_keymap_cursor(&mut self, settings: &Settings, keymap_name: &str) {\n        let cursor_style = if keymap_name == \"__clear__\" {\n            None\n        } else {\n            settings.keymap_cursor.get(keymap_name).copied()\n        }\n        .or_else(|| self.current_cursor.map(|_| CursorStyle::DefaultUserShape));\n\n        if cursor_style != self.current_cursor\n            && let Some(style) = cursor_style\n        {\n            self.current_cursor = cursor_style;\n            let _ = execute!(stdout(), Self::cast_cursor_style(style));\n        }\n    }\n\n    pub fn initialize_keymap_cursor(&mut self, settings: &Settings) {\n        match self.keymap_mode {\n            KeymapMode::Emacs => self.set_keymap_cursor(settings, \"emacs\"),\n            KeymapMode::VimNormal => self.set_keymap_cursor(settings, \"vim_normal\"),\n            KeymapMode::VimInsert => self.set_keymap_cursor(settings, \"vim_insert\"),\n            KeymapMode::Auto => {}\n        }\n    }\n\n    pub fn finalize_keymap_cursor(&mut self, settings: &Settings) {\n        match settings.keymap_mode_shell {\n            KeymapMode::Emacs => self.set_keymap_cursor(settings, \"emacs\"),\n            KeymapMode::VimNormal => self.set_keymap_cursor(settings, \"vim_normal\"),\n            KeymapMode::VimInsert => self.set_keymap_cursor(settings, \"vim_insert\"),\n            KeymapMode::Auto => self.set_keymap_cursor(settings, \"__clear__\"),\n        }\n    }\n\n    fn handle_key_exit(settings: &Settings) -> InputAction {\n        match settings.exit_mode {\n            ExitMode::ReturnOriginal => InputAction::ReturnOriginal,\n            ExitMode::ReturnQuery => InputAction::ReturnQuery,\n        }\n    }\n\n    /// Select the keymap for the current mode (ignoring prefix).\n    fn mode_keymap(&self) -> &super::keybindings::Keymap {\n        if self.tab_index == 1 {\n            &self.keymaps.inspector\n        } else {\n            match self.keymap_mode {\n                KeymapMode::Emacs | KeymapMode::Auto => &self.keymaps.emacs,\n                KeymapMode::VimNormal => &self.keymaps.vim_normal,\n                KeymapMode::VimInsert => &self.keymaps.vim_insert,\n            }\n        }\n    }\n\n    /// Whether the current mode supports character insertion on unmatched keys.\n    fn is_insert_mode(&self) -> bool {\n        matches!(\n            self.keymap_mode,\n            KeymapMode::Emacs | KeymapMode::Auto | KeymapMode::VimInsert\n        )\n    }\n\n    fn handle_key_input(&mut self, settings: &Settings, input: &KeyEvent) -> InputAction {\n        use super::keybindings::Action;\n        use super::keybindings::EvalContext;\n        use super::keybindings::key::{KeyCodeValue, KeyInput, SingleKey};\n\n        // Skip release events\n        if input.kind == event::KeyEventKind::Release {\n            return InputAction::Continue;\n        }\n\n        // Reset switched_search_mode at start of each key event\n        self.switched_search_mode = false;\n\n        // Build evaluation context from current state\n        let ctx = EvalContext {\n            cursor_position: self.search.input.position(),\n            input_width: UnicodeWidthStr::width(self.search.input.as_str()),\n            input_byte_len: self.search.input.as_str().len(),\n            selected_index: self.results_state.selected(),\n            results_len: self.results_len,\n            original_input_empty: self.original_input_empty,\n            has_context: self.search.custom_context.is_some(),\n        };\n\n        // Convert KeyEvent to SingleKey\n        let Some(single) = SingleKey::from_event(input) else {\n            return InputAction::Continue;\n        };\n\n        // --- Phase 1: Resolve (take pending key first, then immutable borrows) ---\n\n        // Take pending key before any immutable borrows of self\n        let pending = self.pending_vim_key.take();\n\n        // If in prefix mode, try prefix keymap first (single keys only)\n        let prefix_action = if self.prefix {\n            let ki = KeyInput::Single(single.clone());\n            self.keymaps.prefix.resolve(&ki, &ctx)\n        } else {\n            None\n        };\n\n        // The if-let/else-if chain here is clearer than map_or_else with nested closures.\n        #[allow(clippy::option_if_let_else)]\n        let (action, new_pending) = if prefix_action.is_some() {\n            (prefix_action, None)\n        } else {\n            // Use mode keymap (handles both single and multi-key sequences)\n            let keymap = self.mode_keymap();\n\n            if let Some(pending_char) = pending {\n                // We have a pending key from a previous press (e.g., first 'g' of 'gg')\n                let pending_single = SingleKey {\n                    code: KeyCodeValue::Char(pending_char),\n                    ctrl: false,\n                    alt: false,\n                    shift: false,\n                    super_key: false,\n                };\n                let seq = KeyInput::Sequence(vec![pending_single, single.clone()]);\n                let action = keymap\n                    .resolve(&seq, &ctx)\n                    .or_else(|| keymap.resolve(&KeyInput::Single(single.clone()), &ctx));\n                (action, None)\n            } else if keymap.has_sequence_starting_with(&single)\n                && matches!(single.code, KeyCodeValue::Char(_))\n                && !single.ctrl\n                && !single.alt\n            {\n                // This key starts a multi-key sequence; wait for next key\n                let KeyCodeValue::Char(c) = single.code else {\n                    unreachable!()\n                };\n                (Some(Action::Noop), Some(c))\n            } else {\n                (\n                    keymap.resolve(&KeyInput::Single(single.clone()), &ctx),\n                    None,\n                )\n            }\n        };\n\n        // --- Phase 2: Apply mutations ---\n        self.pending_vim_key = new_pending;\n\n        // Reset prefix (before execute, so EnterPrefixMode can re-set it)\n        self.prefix = false;\n\n        if let Some(action) = action {\n            self.execute_action(&action, settings)\n        } else {\n            // No action matched. In insert-capable modes, insert the character.\n            if self.is_insert_mode() && !single.ctrl && !single.alt {\n                match single.code {\n                    KeyCodeValue::Char(c) => {\n                        self.search.input.insert(c);\n                    }\n                    KeyCodeValue::Space => {\n                        self.search.input.insert(' ');\n                    }\n                    _ => {}\n                }\n            }\n            InputAction::Continue\n        }\n    }\n\n    fn scroll_down(&mut self, scroll_len: usize) {\n        let i = self.results_state.selected().saturating_sub(scroll_len);\n        self.inspecting_state.reset();\n        self.results_state.select(i);\n    }\n\n    fn scroll_up(&mut self, scroll_len: usize) {\n        let i = self.results_state.selected() + scroll_len;\n        self.results_state\n            .select(i.min(self.results_len.saturating_sub(1)));\n        self.inspecting_state.reset();\n    }\n\n    /// Execute a resolved action, performing all side effects and returning the\n    /// appropriate `InputAction` for the event loop.\n    ///\n    /// This is the \"do it\" half of the resolve+execute pipeline. The resolver\n    /// decides *what* to do (which `Action`), and this function carries it out.\n    ///\n    /// Invert handling: scroll actions (`SelectNext`, `ScrollPageDown`, etc.) account\n    /// for `settings.invert` so that keybindings are always in \"visual\" terms —\n    /// users never need to think about invert in their keybinding config.\n    #[allow(clippy::too_many_lines)]\n    pub(crate) fn execute_action(\n        &mut self,\n        action: &super::keybindings::Action,\n        settings: &Settings,\n    ) -> InputAction {\n        use crate::command::client::search::keybindings::Action;\n\n        match action {\n            // -- Cursor movement --\n            Action::CursorLeft => {\n                self.search.input.left();\n                InputAction::Continue\n            }\n            Action::CursorRight => {\n                self.search.input.right();\n                InputAction::Continue\n            }\n            Action::CursorWordLeft => {\n                self.search\n                    .input\n                    .prev_word(&settings.word_chars, settings.word_jump_mode);\n                InputAction::Continue\n            }\n            Action::CursorWordRight => {\n                self.search\n                    .input\n                    .next_word(&settings.word_chars, settings.word_jump_mode);\n                InputAction::Continue\n            }\n            Action::CursorWordEnd => {\n                self.search.input.word_end(&settings.word_chars);\n                InputAction::Continue\n            }\n            Action::CursorStart => {\n                self.search.input.start();\n                InputAction::Continue\n            }\n            Action::CursorEnd => {\n                self.search.input.end();\n                InputAction::Continue\n            }\n\n            // -- Editing --\n            Action::DeleteCharBefore => {\n                self.search.input.back();\n                InputAction::Continue\n            }\n            Action::DeleteCharAfter => {\n                self.search.input.remove();\n                InputAction::Continue\n            }\n            Action::DeleteWordBefore => {\n                self.search\n                    .input\n                    .remove_prev_word(&settings.word_chars, settings.word_jump_mode);\n                InputAction::Continue\n            }\n            Action::DeleteWordAfter => {\n                self.search\n                    .input\n                    .remove_next_word(&settings.word_chars, settings.word_jump_mode);\n                InputAction::Continue\n            }\n            Action::DeleteToWordBoundary => {\n                // ctrl-w: remove trailing whitespace, then delete to word boundary\n                while matches!(self.search.input.back(), Some(c) if c.is_whitespace()) {}\n                while self.search.input.left() {\n                    if self.search.input.char().unwrap().is_whitespace() {\n                        self.search.input.right();\n                        break;\n                    }\n                    self.search.input.remove();\n                }\n                InputAction::Continue\n            }\n            Action::ClearLine => {\n                self.search.input.clear();\n                InputAction::Continue\n            }\n            Action::ClearToStart => {\n                self.search.input.clear_to_start();\n                InputAction::Continue\n            }\n            Action::ClearToEnd => {\n                self.search.input.clear_to_end();\n                InputAction::Continue\n            }\n\n            // -- List navigation (invert-aware) --\n            Action::SelectNext => {\n                if settings.invert {\n                    self.scroll_up(1);\n                } else {\n                    self.scroll_down(1);\n                }\n                InputAction::Continue\n            }\n            Action::SelectPrevious => {\n                if settings.invert {\n                    self.scroll_down(1);\n                } else {\n                    self.scroll_up(1);\n                }\n                InputAction::Continue\n            }\n            // -- Page/half-page scroll (invert-aware) --\n            Action::ScrollHalfPageUp => {\n                let scroll_len = self\n                    .results_state\n                    .max_entries()\n                    .saturating_sub(settings.scroll_context_lines)\n                    / 2;\n                if settings.invert {\n                    self.scroll_down(scroll_len);\n                } else {\n                    self.scroll_up(scroll_len);\n                }\n                InputAction::Continue\n            }\n            Action::ScrollHalfPageDown => {\n                let scroll_len = self\n                    .results_state\n                    .max_entries()\n                    .saturating_sub(settings.scroll_context_lines)\n                    / 2;\n                if settings.invert {\n                    self.scroll_up(scroll_len);\n                } else {\n                    self.scroll_down(scroll_len);\n                }\n                InputAction::Continue\n            }\n            Action::ScrollPageUp => {\n                let scroll_len = self\n                    .results_state\n                    .max_entries()\n                    .saturating_sub(settings.scroll_context_lines);\n                if settings.invert {\n                    self.scroll_down(scroll_len);\n                } else {\n                    self.scroll_up(scroll_len);\n                }\n                InputAction::Continue\n            }\n            Action::ScrollPageDown => {\n                let scroll_len = self\n                    .results_state\n                    .max_entries()\n                    .saturating_sub(settings.scroll_context_lines);\n                if settings.invert {\n                    self.scroll_up(scroll_len);\n                } else {\n                    self.scroll_down(scroll_len);\n                }\n                InputAction::Continue\n            }\n\n            // -- Absolute jumps (invert-aware) --\n            Action::ScrollToTop => {\n                // Visual top of history\n                if settings.invert {\n                    self.results_state.select(0);\n                } else {\n                    let last_idx = self.results_len.saturating_sub(1);\n                    self.results_state.select(last_idx);\n                }\n                self.inspecting_state.reset();\n                InputAction::Continue\n            }\n            Action::ScrollToBottom => {\n                // Visual bottom of history\n                if settings.invert {\n                    let last_idx = self.results_len.saturating_sub(1);\n                    self.results_state.select(last_idx);\n                } else {\n                    self.results_state.select(0);\n                }\n                self.inspecting_state.reset();\n                InputAction::Continue\n            }\n            Action::ScrollToScreenTop => {\n                // H — jump to top of visible screen\n                let top = self.results_state.offset();\n                let visible = self.results_state.max_entries().min(self.results_len);\n                let bottom = top + visible.saturating_sub(1);\n                self.results_state\n                    .select(bottom.min(self.results_len.saturating_sub(1)));\n                self.inspecting_state.reset();\n                InputAction::Continue\n            }\n            Action::ScrollToScreenMiddle => {\n                // M — jump to middle of visible screen\n                let top = self.results_state.offset();\n                let visible = self.results_state.max_entries().min(self.results_len);\n                let middle = top + visible / 2;\n                self.results_state\n                    .select(middle.min(self.results_len.saturating_sub(1)));\n                self.inspecting_state.reset();\n                InputAction::Continue\n            }\n            Action::ScrollToScreenBottom => {\n                // L — jump to bottom of visible screen\n                let top_visible = self.results_state.offset();\n                self.results_state.select(top_visible);\n                self.inspecting_state.reset();\n                InputAction::Continue\n            }\n\n            // -- Commands --\n            Action::Accept => {\n                if self.tab_index == 1 {\n                    return InputAction::AcceptInspecting;\n                }\n                self.accept = true;\n                InputAction::Accept(self.results_state.selected())\n            }\n            Action::AcceptNth(n) => {\n                self.accept = true;\n                InputAction::Accept(self.results_state.selected() + *n as usize)\n            }\n            Action::ReturnSelection => {\n                if self.tab_index == 1 {\n                    return InputAction::AcceptInspecting;\n                }\n                InputAction::Accept(self.results_state.selected())\n            }\n            Action::ReturnSelectionNth(n) => {\n                InputAction::Accept(self.results_state.selected() + *n as usize)\n            }\n            Action::Copy => InputAction::Copy(self.results_state.selected()),\n            Action::Delete => InputAction::Delete(self.results_state.selected()),\n            Action::ReturnOriginal => InputAction::ReturnOriginal,\n            Action::ReturnQuery => InputAction::ReturnQuery,\n            Action::Exit => Self::handle_key_exit(settings),\n            Action::Redraw => InputAction::Redraw,\n            Action::CycleFilterMode => {\n                self.search.rotate_filter_mode(settings, 1);\n                InputAction::Continue\n            }\n            Action::CycleSearchMode => {\n                self.switched_search_mode = true;\n                self.search_mode = self.search_mode.next(settings);\n                self.engine = engines::engine(self.search_mode, settings);\n                InputAction::Continue\n            }\n            Action::SwitchContext => {\n                InputAction::SwitchContext(Some(self.results_state.selected()))\n            }\n            Action::ClearContext => InputAction::SwitchContext(None),\n            Action::ToggleTab => {\n                self.tab_index = (self.tab_index + 1) % TAB_TITLES.len();\n                InputAction::Continue\n            }\n\n            // -- Mode changes --\n            Action::VimEnterNormal => {\n                self.set_keymap_cursor(settings, \"vim_normal\");\n                self.keymap_mode = KeymapMode::VimNormal;\n                InputAction::Continue\n            }\n            Action::VimEnterInsert => {\n                self.set_keymap_cursor(settings, \"vim_insert\");\n                self.keymap_mode = KeymapMode::VimInsert;\n                InputAction::Continue\n            }\n            Action::VimEnterInsertAfter => {\n                self.search.input.right();\n                self.set_keymap_cursor(settings, \"vim_insert\");\n                self.keymap_mode = KeymapMode::VimInsert;\n                InputAction::Continue\n            }\n            Action::VimEnterInsertAtStart => {\n                self.search.input.start();\n                self.set_keymap_cursor(settings, \"vim_insert\");\n                self.keymap_mode = KeymapMode::VimInsert;\n                InputAction::Continue\n            }\n            Action::VimEnterInsertAtEnd => {\n                self.search.input.end();\n                self.set_keymap_cursor(settings, \"vim_insert\");\n                self.keymap_mode = KeymapMode::VimInsert;\n                InputAction::Continue\n            }\n            Action::VimSearchInsert => {\n                self.search.input.clear();\n                self.set_keymap_cursor(settings, \"vim_insert\");\n                self.keymap_mode = KeymapMode::VimInsert;\n                InputAction::Continue\n            }\n            Action::VimChangeToEnd => {\n                self.search.input.clear_to_end();\n                self.set_keymap_cursor(settings, \"vim_insert\");\n                self.keymap_mode = KeymapMode::VimInsert;\n                InputAction::Continue\n            }\n            Action::EnterPrefixMode => {\n                self.prefix = true;\n                InputAction::Continue\n            }\n\n            // -- Inspector --\n            Action::InspectPrevious => {\n                self.inspecting_state.move_to_previous();\n                InputAction::Redraw\n            }\n            Action::InspectNext => {\n                self.inspecting_state.move_to_next();\n                InputAction::Redraw\n            }\n\n            // -- Special --\n            Action::Noop => InputAction::Continue,\n        }\n    }\n\n    #[allow(clippy::cast_possible_truncation)]\n    #[allow(clippy::bool_to_int_with_if)]\n    fn calc_preview_height(\n        settings: &Settings,\n        results: &[History],\n        selected: usize,\n        tab_index: usize,\n        compactness: Compactness,\n        border_size: u16,\n        preview_width: u16,\n    ) -> u16 {\n        if settings.show_preview\n            && settings.preview.strategy == PreviewStrategy::Auto\n            && tab_index == 0\n            && !results.is_empty()\n        {\n            let length_current_cmd = results[selected].command.len() as u16;\n            // calculate the number of newlines in the command\n            let num_newlines = results[selected]\n                .command\n                .chars()\n                .filter(|&c| c == '\\n')\n                .count() as u16;\n            if num_newlines > 0 {\n                std::cmp::min(\n                    settings.max_preview_height,\n                    results[selected]\n                        .command\n                        .split('\\n')\n                        .map(|line| {\n                            (line.len() as u16 + preview_width - 1 - border_size)\n                                / (preview_width - border_size)\n                        })\n                        .sum(),\n                ) + border_size * 2\n            }\n            // The '- 19' takes the characters before the command (duration and time) into account\n            else if length_current_cmd > preview_width - 19 {\n                std::cmp::min(\n                    settings.max_preview_height,\n                    (length_current_cmd + preview_width - 1 - border_size)\n                        / (preview_width - border_size),\n                ) + border_size * 2\n            } else {\n                1\n            }\n        } else if settings.show_preview\n            && settings.preview.strategy == PreviewStrategy::Static\n            && tab_index == 0\n        {\n            let longest_command = results\n                .iter()\n                .max_by(|h1, h2| h1.command.len().cmp(&h2.command.len()));\n            longest_command.map_or(0, |v| {\n                std::cmp::min(\n                    settings.max_preview_height,\n                    v.command\n                        .split('\\n')\n                        .map(|line| {\n                            (line.len() as u16 + preview_width - 1 - border_size)\n                                / (preview_width - border_size)\n                        })\n                        .sum(),\n                )\n            }) + border_size * 2\n        } else if settings.show_preview && settings.preview.strategy == PreviewStrategy::Fixed {\n            settings.max_preview_height + border_size * 2\n        } else if !matches!(compactness, Compactness::Full) || tab_index == 1 {\n            0\n        } else {\n            1\n        }\n    }\n\n    #[allow(clippy::bool_to_int_with_if)]\n    #[allow(clippy::too_many_lines)]\n    #[allow(clippy::too_many_arguments)]\n    fn draw(\n        &mut self,\n        f: &mut Frame,\n        results: &[History],\n        stats: Option<HistoryStats>,\n        inspecting: Option<&History>,\n        settings: &Settings,\n        theme: &Theme,\n        popup_mode: bool,\n    ) {\n        let area = f.area();\n        if popup_mode {\n            f.render_widget(Clear, area);\n        }\n        self.draw_inner(f, area, results, stats, inspecting, settings, theme);\n    }\n\n    #[allow(clippy::too_many_arguments)]\n    #[allow(clippy::too_many_lines)]\n    #[allow(clippy::bool_to_int_with_if)]\n    fn draw_inner(\n        &mut self,\n        f: &mut Frame,\n        area: Rect,\n        results: &[History],\n        stats: Option<HistoryStats>,\n        inspecting: Option<&History>,\n        settings: &Settings,\n        theme: &Theme,\n    ) {\n        let compactness = to_compactness(f, settings);\n        let invert = settings.invert;\n        let border_size = match compactness {\n            Compactness::Full => 1,\n            _ => 0,\n        };\n        let preview_width = area.width.saturating_sub(2);\n        let preview_height = Self::calc_preview_height(\n            settings,\n            results,\n            self.results_state.selected(),\n            self.tab_index,\n            compactness,\n            border_size,\n            preview_width,\n        );\n        let show_help =\n            settings.show_help && (matches!(compactness, Compactness::Full) || area.height > 1);\n        // This is an OR, as it seems more likely for someone to wish to override\n        // tabs unexpectedly being missed, than unexpectedly present.\n        let show_tabs = settings.show_tabs && !matches!(compactness, Compactness::Ultracompact);\n        let chunks = Layout::default()\n            .direction(Direction::Vertical)\n            .margin(0)\n            .horizontal_margin(1)\n            .constraints::<&[Constraint]>(\n                if invert {\n                    [\n                        Constraint::Length(1 + border_size),               // input\n                        Constraint::Min(1),                                // results list\n                        Constraint::Length(preview_height),                // preview\n                        Constraint::Length(if show_tabs { 1 } else { 0 }), // tabs\n                        Constraint::Length(if show_help { 1 } else { 0 }), // header (sic)\n                    ]\n                } else {\n                    match compactness {\n                        Compactness::Ultracompact => [\n                            Constraint::Length(if show_help { 1 } else { 0 }), // header\n                            Constraint::Length(0),                             // tabs\n                            Constraint::Min(1),                                // results list\n                            Constraint::Length(0),\n                            Constraint::Length(0),\n                        ],\n                        _ => [\n                            Constraint::Length(if show_help { 1 } else { 0 }), // header\n                            Constraint::Length(if show_tabs { 1 } else { 0 }), // tabs\n                            Constraint::Min(1),                                // results list\n                            Constraint::Length(1 + border_size),               // input\n                            Constraint::Length(preview_height),                // preview\n                        ],\n                    }\n                }\n                .as_ref(),\n            )\n            .split(area);\n\n        let input_chunk = if invert { chunks[0] } else { chunks[3] };\n        let results_list_chunk = if invert { chunks[1] } else { chunks[2] };\n        let preview_chunk = if invert { chunks[2] } else { chunks[4] };\n        let tabs_chunk = if invert { chunks[3] } else { chunks[1] };\n        let header_chunk = if invert { chunks[4] } else { chunks[0] };\n\n        // TODO: this should be split so that we have one interactive search container that is\n        // EITHER a search box or an inspector. But I'm not doing that now, way too much atm.\n        // also allocate less 🙈\n        let titles: Vec<_> = TAB_TITLES.iter().copied().map(Line::from).collect();\n\n        if show_tabs {\n            let tabs = Tabs::new(titles)\n                .block(Block::default().borders(Borders::NONE))\n                .select(self.tab_index)\n                .style(Style::default())\n                .highlight_style(Style::from_crossterm(theme.as_style(Meaning::Important)));\n\n            f.render_widget(tabs, tabs_chunk);\n        }\n\n        let style = StyleState {\n            compactness,\n            invert,\n            inner_width: input_chunk.width.into(),\n        };\n\n        let header_chunks = Layout::default()\n            .direction(Direction::Horizontal)\n            .constraints::<&[Constraint]>(\n                [\n                    Constraint::Ratio(1, 5),\n                    Constraint::Ratio(3, 5),\n                    Constraint::Ratio(1, 5),\n                ]\n                .as_ref(),\n            )\n            .split(header_chunk);\n\n        let title = self.build_title(theme);\n        f.render_widget(title, header_chunks[0]);\n\n        let help = self.build_help(settings, theme);\n        f.render_widget(help, header_chunks[1]);\n\n        let stats_tab = self.build_stats(theme);\n        f.render_widget(stats_tab, header_chunks[2]);\n\n        let indicator: String = match compactness {\n            Compactness::Ultracompact => {\n                if self.switched_search_mode {\n                    format!(\"S{}>\", self.search_mode.as_str().chars().next().unwrap())\n                } else if self.search.custom_context.is_some() {\n                    format!(\n                        \"C{}>\",\n                        self.search.filter_mode.as_str().chars().next().unwrap()\n                    )\n                } else {\n                    format!(\n                        \"{}> \",\n                        self.search.filter_mode.as_str().chars().next().unwrap()\n                    )\n                }\n            }\n            _ => \" > \".to_string(),\n        };\n\n        match self.tab_index {\n            0 => {\n                let history_highlighter = HistoryHighlighter {\n                    engine: self.engine.as_ref(),\n                    search_input: self.search.input.as_str(),\n                };\n                let results_list = Self::build_results_list(\n                    style,\n                    results,\n                    self.keymap_mode,\n                    &self.now,\n                    indicator.as_str(),\n                    theme,\n                    history_highlighter,\n                    settings.show_numeric_shortcuts,\n                    &settings.ui.columns,\n                );\n                f.render_stateful_widget(results_list, results_list_chunk, &mut self.results_state);\n            }\n\n            1 => {\n                if results.is_empty() {\n                    let message = Paragraph::new(\"Nothing to inspect\")\n                        .block(\n                            Block::new()\n                                .title(Line::from(\" Info \".to_string()))\n                                .title_alignment(Alignment::Center)\n                                .borders(Borders::ALL)\n                                .padding(Padding::vertical(2)),\n                        )\n                        .alignment(Alignment::Center);\n                    f.render_widget(message, results_list_chunk);\n                } else {\n                    let inspecting = match inspecting {\n                        Some(inspecting) => inspecting,\n                        None => &results[self.results_state.selected()],\n                    };\n                    super::inspector::draw(\n                        f,\n                        results_list_chunk,\n                        inspecting,\n                        &stats.expect(\"Drawing inspector, but no stats\"),\n                        settings,\n                        theme,\n                        settings.timezone,\n                    );\n                }\n\n                // HACK: I'm following up with abstracting this into the UI container, with a\n                // sub-widget for search + for inspector\n                let feedback = Paragraph::new(\n                    \"The inspector is new - please give feedback (good, or bad) at https://forum.atuin.sh\",\n                );\n                f.render_widget(feedback, input_chunk);\n\n                return;\n            }\n\n            _ => {\n                panic!(\"invalid tab index\");\n            }\n        }\n\n        if !matches!(compactness, Compactness::Ultracompact) {\n            let preview_width = match compactness {\n                Compactness::Full => preview_width - 2,\n                _ => preview_width,\n            };\n            let preview = self.build_preview(\n                results,\n                compactness,\n                preview_width,\n                preview_chunk.width.into(),\n                theme,\n            );\n            #[allow(clippy::cast_possible_truncation)]\n            let prefix_width = settings\n                .ui\n                .columns\n                .iter()\n                .take_while(|col| !col.expand)\n                .map(|col| col.width + 1)\n                .sum::<u16>()\n                + \" > \".len() as u16;\n            #[allow(clippy::cast_possible_truncation)]\n            let min_prefix_width = \"[ SRCH: FULLTXT ] \".len() as u16;\n            self.draw_preview(\n                f,\n                style,\n                input_chunk,\n                compactness,\n                preview_chunk,\n                preview,\n                std::cmp::max(prefix_width, min_prefix_width),\n            );\n        }\n    }\n\n    #[allow(clippy::cast_possible_truncation, clippy::too_many_arguments)]\n    fn draw_preview(\n        &self,\n        f: &mut Frame,\n        style: StyleState,\n        input_chunk: Rect,\n        compactness: Compactness,\n        preview_chunk: Rect,\n        preview: Paragraph,\n        prefix_width: u16,\n    ) {\n        let input = self.build_input(style, prefix_width);\n        f.render_widget(input, input_chunk);\n\n        f.render_widget(preview, preview_chunk);\n\n        let extra_width = UnicodeWidthStr::width(self.search.input.substring());\n\n        let cursor_offset = match compactness {\n            Compactness::Full => 1,\n            _ => 0,\n        };\n        f.set_cursor_position((\n            // Put cursor past the end of the input text\n            input_chunk.x + extra_width as u16 + prefix_width + cursor_offset,\n            input_chunk.y + cursor_offset,\n        ));\n    }\n\n    fn build_title(&self, theme: &Theme) -> Paragraph<'_> {\n        let title = if self.update_needed.is_some() {\n            let error_style: Style = Style::from_crossterm(theme.get_error());\n            Paragraph::new(Text::from(Span::styled(\n                format!(\"Atuin v{VERSION} - UPDATE\"),\n                error_style.add_modifier(Modifier::BOLD),\n            )))\n        } else {\n            let style: Style = Style::from_crossterm(theme.as_style(Meaning::Base));\n            Paragraph::new(Text::from(Span::styled(\n                format!(\"Atuin v{VERSION}\"),\n                style.add_modifier(Modifier::BOLD),\n            )))\n        };\n        title.alignment(Alignment::Left)\n    }\n\n    #[allow(clippy::unused_self)]\n    fn build_help(&self, settings: &Settings, theme: &Theme) -> Paragraph<'_> {\n        match self.tab_index {\n            // search\n            0 => Paragraph::new(Text::from(Line::from(vec![\n                Span::styled(\"<esc>\", Style::default().add_modifier(Modifier::BOLD)),\n                Span::raw(\": exit\"),\n                Span::raw(\", \"),\n                Span::styled(\"<tab>\", Style::default().add_modifier(Modifier::BOLD)),\n                Span::raw(\": edit\"),\n                Span::raw(\", \"),\n                Span::styled(\"<enter>\", Style::default().add_modifier(Modifier::BOLD)),\n                Span::raw(if settings.enter_accept {\n                    \": run\"\n                } else {\n                    \": edit\"\n                }),\n                Span::raw(\", \"),\n                Span::styled(\"<ctrl-o>\", Style::default().add_modifier(Modifier::BOLD)),\n                Span::raw(\": inspect\"),\n            ]))),\n\n            1 => Paragraph::new(Text::from(Line::from(vec![\n                Span::styled(\"<esc>\", Style::default().add_modifier(Modifier::BOLD)),\n                Span::raw(\": exit\"),\n                Span::raw(\", \"),\n                Span::styled(\"<ctrl-o>\", Style::default().add_modifier(Modifier::BOLD)),\n                Span::raw(\": search\"),\n                Span::raw(\", \"),\n                Span::styled(\"<ctrl-d>\", Style::default().add_modifier(Modifier::BOLD)),\n                Span::raw(\": delete\"),\n            ]))),\n\n            _ => unreachable!(\"invalid tab index\"),\n        }\n        .style(Style::from_crossterm(theme.as_style(Meaning::Annotation)))\n        .alignment(Alignment::Center)\n    }\n\n    fn build_stats(&self, theme: &Theme) -> Paragraph<'_> {\n        Paragraph::new(Text::from(Span::raw(format!(\n            \"history count: {}\",\n            self.history_count,\n        ))))\n        .style(Style::from_crossterm(theme.as_style(Meaning::Annotation)))\n        .alignment(Alignment::Right)\n    }\n\n    #[allow(clippy::too_many_arguments)]\n    fn build_results_list<'a>(\n        style: StyleState,\n        results: &'a [History],\n        keymap_mode: KeymapMode,\n        now: &'a dyn Fn() -> OffsetDateTime,\n        indicator: &'a str,\n        theme: &'a Theme,\n        history_highlighter: HistoryHighlighter<'a>,\n        show_numeric_shortcuts: bool,\n        columns: &'a [UiColumn],\n    ) -> HistoryList<'a> {\n        let results_list = HistoryList::new(\n            results,\n            style.invert,\n            keymap_mode == KeymapMode::VimNormal,\n            now,\n            indicator,\n            theme,\n            history_highlighter,\n            show_numeric_shortcuts,\n            columns,\n        );\n\n        match style.compactness {\n            Compactness::Full => {\n                if style.invert {\n                    results_list.block(\n                        Block::default()\n                            .borders(Borders::LEFT | Borders::RIGHT)\n                            .border_type(BorderType::Rounded)\n                            .title(format!(\"{:─>width$}\", \"\", width = style.inner_width - 2)),\n                    )\n                } else {\n                    results_list.block(\n                        Block::default()\n                            .borders(Borders::TOP | Borders::LEFT | Borders::RIGHT)\n                            .border_type(BorderType::Rounded),\n                    )\n                }\n            }\n            _ => results_list,\n        }\n    }\n\n    fn build_input(&self, style: StyleState, prefix_width: u16) -> Paragraph<'_> {\n        let (pref, mode) = if self.switched_search_mode {\n            (\" SRCH:\", self.search_mode.as_str())\n        } else if self.search.custom_context.is_some() {\n            (\" CTX:\", self.search.filter_mode.as_str())\n        } else {\n            (\"\", self.search.filter_mode.as_str())\n        };\n        // 3: surrounding \"[\" \"] \"\n        let mode_width = usize::from(prefix_width) - pref.len() - 3;\n        // sanity check to ensure we don't exceed the layout limits\n        debug_assert!(mode_width >= mode.len(), \"mode name '{mode}' is too long!\");\n        let input = format!(\"[{pref}{mode:^mode_width$}] {}\", self.search.input.as_str(),);\n        let input = Paragraph::new(input);\n        match style.compactness {\n            Compactness::Full => {\n                if style.invert {\n                    input.block(\n                        Block::default()\n                            .borders(Borders::LEFT | Borders::RIGHT | Borders::TOP)\n                            .border_type(BorderType::Rounded),\n                    )\n                } else {\n                    input.block(\n                        Block::default()\n                            .borders(Borders::LEFT | Borders::RIGHT)\n                            .border_type(BorderType::Rounded)\n                            .title(format!(\"{:─>width$}\", \"\", width = style.inner_width - 2)),\n                    )\n                }\n            }\n            _ => input,\n        }\n    }\n\n    fn build_preview(\n        &self,\n        results: &[History],\n        compactness: Compactness,\n        preview_width: u16,\n        chunk_width: usize,\n        theme: &Theme,\n    ) -> Paragraph<'_> {\n        let selected = self.results_state.selected();\n        let command = if results.is_empty() {\n            String::new()\n        } else {\n            use itertools::Itertools as _;\n            let s = &results[selected].command;\n            s.split('\\n')\n                .flat_map(|line| {\n                    line.char_indices()\n                        .step_by(preview_width.into())\n                        .map(|(i, _)| i)\n                        .chain(Some(line.len()))\n                        .tuple_windows()\n                        .map(|(a, b)| (&line[a..b]).escape_control().to_string())\n                })\n                .join(\"\\n\")\n        };\n\n        match compactness {\n            Compactness::Full => Paragraph::new(command).block(\n                Block::default()\n                    .borders(Borders::BOTTOM | Borders::LEFT | Borders::RIGHT)\n                    .border_type(BorderType::Rounded)\n                    .title(format!(\"{:─>width$}\", \"\", width = chunk_width - 2)),\n            ),\n            _ => Paragraph::new(command)\n                .style(Style::from_crossterm(theme.as_style(Meaning::Annotation))),\n        }\n    }\n}\n\n/// The writer used for terminal output - either stdout or /dev/tty\nenum TerminalWriter {\n    Stdout(std::io::Stdout),\n    #[cfg(unix)]\n    Tty(std::fs::File),\n}\n\nimpl Write for TerminalWriter {\n    fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {\n        match self {\n            TerminalWriter::Stdout(stdout) => stdout.write(buf),\n            #[cfg(unix)]\n            TerminalWriter::Tty(file) => file.write(buf),\n        }\n    }\n\n    fn flush(&mut self) -> std::io::Result<()> {\n        match self {\n            TerminalWriter::Stdout(stdout) => stdout.flush(),\n            #[cfg(unix)]\n            TerminalWriter::Tty(file) => file.flush(),\n        }\n    }\n}\n\n/// Screen state captured from atuin-hex's screen server.\n#[cfg(unix)]\nstruct SavedScreen {\n    #[allow(dead_code)]\n    rows: u16,\n    #[allow(dead_code)]\n    cols: u16,\n    cursor_row: u16,\n    cursor_col: u16,\n    /// Pre-formatted ANSI bytes for each screen row, ready to write to stdout.\n    rows_data: Vec<Vec<u8>>,\n}\n\n/// Connect to atuin-hex's Unix socket and fetch the current screen state.\n///\n/// The wire format is:\n/// ```text\n/// [rows: u16 BE][cols: u16 BE][cursor_row: u16 BE][cursor_col: u16 BE]\n/// [row_0_len: u32 BE][row_0_bytes...]\n/// [row_1_len: u32 BE][row_1_bytes...]\n/// ...\n/// ```\n#[cfg(unix)]\nfn fetch_screen_state(socket_path: &str) -> Option<SavedScreen> {\n    use std::os::unix::net::UnixStream;\n\n    let mut stream = UnixStream::connect(socket_path).ok()?;\n    stream.set_read_timeout(Some(Duration::from_secs(2))).ok()?;\n\n    let mut data = Vec::new();\n    stream.read_to_end(&mut data).ok()?;\n\n    if data.len() < 8 {\n        return None;\n    }\n\n    let rows = u16::from_be_bytes([data[0], data[1]]);\n    let cols = u16::from_be_bytes([data[2], data[3]]);\n    let cursor_row = u16::from_be_bytes([data[4], data[5]]);\n    let cursor_col = u16::from_be_bytes([data[6], data[7]]);\n\n    // Parse length-prefixed rows\n    let mut rows_data = Vec::with_capacity(rows as usize);\n    let mut offset = 8;\n    while offset + 4 <= data.len() {\n        let row_len = u32::from_be_bytes([\n            data[offset],\n            data[offset + 1],\n            data[offset + 2],\n            data[offset + 3],\n        ]) as usize;\n        offset += 4;\n        if offset + row_len > data.len() {\n            break;\n        }\n        rows_data.push(data[offset..offset + row_len].to_vec());\n        offset += row_len;\n    }\n\n    Some(SavedScreen {\n        rows,\n        cols,\n        cursor_row,\n        cursor_col,\n        rows_data,\n    })\n}\n\n/// Restore the screen area that was covered by the popup.\n///\n/// Writes the pre-formatted per-row ANSI bytes received from atuin-hex\n/// directly to stdout, which correctly handles wide characters, colors, and\n/// all text attributes without needing a client-side vt100 parser.\n#[cfg(unix)]\nfn restore_popup_area(saved: &SavedScreen, popup_rect: Rect, scroll_offset: u16) {\n    use ratatui::crossterm::cursor::MoveTo;\n\n    let mut stdout = stdout();\n\n    for dy in 0..popup_rect.height {\n        let target_row = popup_rect.y + dy;\n        let source_row = (target_row + scroll_offset) as usize;\n\n        // Clear only the popup region. The server-side rows_formatted() skips\n        // default cells (spaces with default attributes) using cursor jumps, so\n        // any popup content at those positions would remain if not cleared\n        // beforehand. We write `popup_rect.width` spaces instead of\n        // ClearType::CurrentLine so that only the popup area is cleared, not\n        // the entire terminal line.\n        let _ = execute!(\n            stdout,\n            MoveTo(popup_rect.x, target_row),\n            ratatui::crossterm::style::SetAttribute(ratatui::crossterm::style::Attribute::Reset),\n        );\n        let _ = write!(stdout, \"{:width$}\", \"\", width = popup_rect.width as usize);\n        let _ = execute!(stdout, MoveTo(popup_rect.x, target_row));\n\n        if let Some(row_bytes) = saved.rows_data.get(source_row) {\n            let _ = stdout.write_all(row_bytes);\n        }\n    }\n\n    let _ = execute!(\n        stdout,\n        MoveTo(\n            saved.cursor_col,\n            saved.cursor_row.saturating_sub(scroll_offset)\n        )\n    );\n    let _ = stdout.flush();\n}\n\nstruct Stdout {\n    writer: TerminalWriter,\n    inline_mode: bool,\n}\n\nimpl Stdout {\n    pub fn new(inline_mode: bool, stdout_is_terminal: bool) -> std::io::Result<Self> {\n        terminal::enable_raw_mode()?;\n\n        // If stdout is not a terminal (e.g., captured by command substitution),\n        // fall back to /dev/tty so the TUI can still render.\n        // This allows usage like: VAR=$(atuin search -i)\n        let mut writer = if stdout_is_terminal {\n            TerminalWriter::Stdout(stdout())\n        } else {\n            #[cfg(unix)]\n            {\n                TerminalWriter::Tty(\n                    std::fs::File::options()\n                        .read(true)\n                        .write(true)\n                        .open(\"/dev/tty\")?,\n                )\n            }\n            #[cfg(not(unix))]\n            {\n                return Err(std::io::Error::new(\n                    std::io::ErrorKind::Unsupported,\n                    \"Interactive mode requires a terminal\",\n                ));\n            }\n        };\n\n        if !inline_mode {\n            execute!(writer, terminal::EnterAlternateScreen)?;\n        }\n\n        execute!(\n            writer,\n            event::EnableMouseCapture,\n            event::EnableBracketedPaste,\n        )?;\n\n        #[cfg(not(target_os = \"windows\"))]\n        execute!(\n            writer,\n            PushKeyboardEnhancementFlags(\n                KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES\n                    | KeyboardEnhancementFlags::REPORT_ALL_KEYS_AS_ESCAPE_CODES\n                    | KeyboardEnhancementFlags::REPORT_ALTERNATE_KEYS\n            ),\n        )?;\n\n        Ok(Self {\n            writer,\n            inline_mode,\n        })\n    }\n}\n\nimpl Drop for Stdout {\n    fn drop(&mut self) {\n        #[cfg(not(target_os = \"windows\"))]\n        execute!(self.writer, PopKeyboardEnhancementFlags).unwrap();\n\n        if !self.inline_mode {\n            execute!(self.writer, terminal::LeaveAlternateScreen).unwrap();\n        }\n        execute!(\n            self.writer,\n            event::DisableMouseCapture,\n            event::DisableBracketedPaste,\n        )\n        .unwrap();\n\n        terminal::disable_raw_mode().unwrap();\n    }\n}\n\nimpl Write for Stdout {\n    fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {\n        self.writer.write(buf)\n    }\n\n    fn flush(&mut self) -> std::io::Result<()> {\n        self.writer.flush()\n    }\n}\n\n// this is a big blob of horrible! clean it up!\n/// Compute the popup position and any scroll offset needed to make room.\n///\n/// Given the cursor row, terminal dimensions, and desired popup height,\n/// returns `(popup_rect, scroll_offset)` where `scroll_offset` is the number\n/// of lines the caller should scroll the terminal up before rendering.\n///\n/// This function performs no I/O — it is a pure computation.\nfn compute_popup_placement(\n    cursor_row: u16,\n    term_rows: u16,\n    term_cols: u16,\n    inline_height: u16,\n) -> (Rect, u16) {\n    let popup_w = term_cols;\n    let popup_h = inline_height.min(term_rows);\n    let space_below = term_rows.saturating_sub(cursor_row);\n\n    let (popup_y, scroll) = if popup_h <= space_below {\n        // Fits below cursor\n        (cursor_row, 0u16)\n    } else if cursor_row >= term_rows / 2 {\n        // Bottom half — render above cursor (overlay on existing text)\n        (cursor_row.saturating_sub(popup_h), 0u16)\n    } else {\n        // Top half, not enough space — scroll terminal to make room\n        let scroll = popup_h.saturating_sub(space_below);\n        let popup_y = cursor_row.saturating_sub(scroll);\n        (popup_y, scroll)\n    };\n\n    (Rect::new(0, popup_y, popup_w, popup_h), scroll)\n}\n\n// for now, it works. But it'd be great if it were more easily readable, and\n// modular. I'd like to add some more stats and stuff at some point\n#[allow(\n    clippy::cast_possible_truncation,\n    clippy::too_many_lines,\n    clippy::cognitive_complexity\n)]\npub async fn history(\n    query: &[String],\n    settings: &Settings,\n    mut db: impl Database,\n    history_store: &HistoryStore,\n    theme: &Theme,\n) -> Result<String> {\n    let inline_height = if settings.shell_up_key_binding {\n        settings\n            .inline_height_shell_up_key_binding\n            .unwrap_or(settings.inline_height)\n    } else {\n        settings.inline_height\n    };\n\n    // Check if stdout is a terminal - if not (e.g., command substitution like VAR=$(atuin search -i)),\n    // we need to use /dev/tty for the TUI and force fullscreen mode (inline mode requires\n    // cursor position queries that don't work when stdout is captured)\n    let stdout_is_terminal = stdout().is_terminal();\n\n    // Use fullscreen mode if the inline height doesn't fit in the terminal,\n    // this will preserve the scroll position upon exit.\n    // Also force fullscreen when stdout isn't a terminal (inline mode won't work).\n    let inline_height = if !stdout_is_terminal {\n        0\n    } else if let Ok(size) = terminal::size()\n        && inline_height >= size.1\n    {\n        0\n    } else {\n        inline_height\n    };\n\n    // Popup mode: if running under atuin-hex and inline mode is requested,\n    // fetch the screen state and render as a centered overlay.\n    #[cfg(unix)]\n    let (saved_screen, popup_rect, popup_scroll_offset) = {\n        let socket_path = std::env::var(\"ATUIN_HEX_SOCKET\").ok();\n        if let Some(ref path) = socket_path\n            && inline_height > 0\n        {\n            let saved = fetch_screen_state(path);\n            if let Some(ref s) = saved {\n                let (term_cols, term_rows) = terminal::size().unwrap_or((s.cols, s.rows));\n                let (popup_rect, scroll) =\n                    compute_popup_placement(s.cursor_row, term_rows, term_cols, inline_height);\n\n                // Scroll terminal content up to make room if needed\n                if scroll > 0 {\n                    use ratatui::crossterm::cursor::MoveTo;\n                    let mut stdout = stdout();\n                    let _ = execute!(stdout, MoveTo(0, term_rows - 1));\n                    for _ in 0..scroll {\n                        let _ = writeln!(stdout);\n                    }\n                    let _ = stdout.flush();\n                }\n\n                (saved, popup_rect, scroll)\n            } else {\n                (None, Rect::default(), 0u16)\n            }\n        } else {\n            (None, Rect::default(), 0u16)\n        }\n    };\n\n    #[cfg(not(unix))]\n    let (saved_screen, popup_rect, popup_scroll_offset): (Option<()>, Rect, u16) =\n        (None, Rect::default(), 0);\n\n    let popup_mode = saved_screen.is_some();\n\n    let stdout = Stdout::new(inline_height > 0, stdout_is_terminal)?;\n\n    // In popup mode, clear the popup region on the physical terminal before\n    // ratatui takes over. Ratatui's diff-based rendering compares against an\n    // initially-empty buffer, so cells that remain \"empty\" (spaces with default\n    // style) won't be written — leaving underlying terminal text visible.\n    // By pre-clearing with spaces, those cells are already correct on screen.\n    if popup_mode {\n        use ratatui::crossterm::cursor::MoveTo;\n        let mut raw_stdout = std::io::stdout();\n        // Queue all commands without flushing so the terminal receives them\n        // as a single write — no intermediate cursor positions are visible.\n        let _ = queue!(\n            raw_stdout,\n            ratatui::crossterm::style::SetAttribute(ratatui::crossterm::style::Attribute::Reset)\n        );\n        for row in popup_rect.y..popup_rect.y.saturating_add(popup_rect.height) {\n            let _ = queue!(raw_stdout, MoveTo(popup_rect.x, row));\n            let _ = write!(\n                raw_stdout,\n                \"{:width$}\",\n                \"\",\n                width = popup_rect.width as usize\n            );\n        }\n        let _ = raw_stdout.flush();\n    }\n\n    let backend = CrosstermBackend::new(stdout);\n    let mut terminal = Terminal::with_options(\n        backend,\n        TerminalOptions {\n            viewport: if popup_mode {\n                Viewport::Fixed(popup_rect)\n            } else if inline_height > 0 {\n                Viewport::Inline(inline_height)\n            } else {\n                Viewport::Fullscreen\n            },\n        },\n    )?;\n\n    let original_query = query.join(\" \");\n\n    // Check if this is a command chaining scenario\n    let is_command_chaining = if settings.command_chaining {\n        let trimmed = original_query.trim_end();\n        trimmed.ends_with(\"&&\") || trimmed.ends_with('|')\n    } else {\n        false\n    };\n\n    // For command chaining, start with empty input to allow searching for new commands\n    let search_input = if is_command_chaining {\n        String::new()\n    } else {\n        original_query.clone()\n    };\n\n    let mut input = Cursor::from(search_input);\n    // Put the cursor at the end of the query by default\n    input.end();\n\n    let settings2 = settings.clone();\n    let update_needed = tokio::spawn(async move { settings2.needs_update().await }).fuse();\n    tokio::pin!(update_needed);\n\n    let initial_context = current_context().await?;\n\n    let history_count = db.history_count(false).await?;\n    let search_mode = if settings.shell_up_key_binding {\n        settings\n            .search_mode_shell_up_key_binding\n            .unwrap_or(settings.search_mode)\n    } else {\n        settings.search_mode\n    };\n    let default_filter_mode = settings\n        .filter_mode_shell_up_key_binding\n        .filter(|_| settings.shell_up_key_binding)\n        .unwrap_or_else(|| settings.default_filter_mode(initial_context.git_root.is_some()));\n    let mut app = State {\n        history_count,\n        results_state: ListState::default(),\n        update_needed: None,\n        switched_search_mode: false,\n        search_mode,\n        tab_index: 0,\n        inspecting_state: InspectingState {\n            current: None,\n            next: None,\n            previous: None,\n        },\n        keymaps: KeymapSet::from_settings(settings),\n        search: SearchState {\n            input,\n            filter_mode: default_filter_mode,\n            context: initial_context.clone(),\n            custom_context: None,\n        },\n        engine: engines::engine(search_mode, settings),\n        results_len: 0,\n        accept: false,\n        keymap_mode: match settings.keymap_mode {\n            KeymapMode::Auto => KeymapMode::Emacs,\n            value => value,\n        },\n        current_cursor: None,\n        now: if settings.prefers_reduced_motion {\n            let now = OffsetDateTime::now_utc();\n            Box::new(move || now)\n        } else {\n            Box::new(OffsetDateTime::now_utc)\n        },\n        prefix: false,\n        pending_vim_key: None,\n        original_input_empty: original_query.is_empty(),\n    };\n\n    app.initialize_keymap_cursor(settings);\n\n    let mut results = app.query_results(&mut db, settings.smart_sort).await?;\n\n    if inline_height > 0 && !popup_mode {\n        terminal.clear()?;\n    }\n\n    let mut stats: Option<HistoryStats> = None;\n    let mut inspecting: Option<History> = None;\n    let accept;\n    let result = 'render: loop {\n        terminal.draw(|f| {\n            app.draw(\n                f,\n                &results,\n                stats.clone(),\n                inspecting.as_ref(),\n                settings,\n                theme,\n                popup_mode,\n            );\n        })?;\n\n        let initial_input = app.search.input.as_str().to_owned();\n        let initial_filter_mode = app.search.filter_mode;\n        let initial_search_mode = app.search_mode;\n        let initial_custom_context = app.search.custom_context.clone();\n\n        let event_ready = tokio::task::spawn_blocking(|| event::poll(Duration::from_millis(250)));\n\n        tokio::select! {\n            event_ready = event_ready => {\n                if event_ready?? {\n                    loop {\n                        match app.handle_input(settings, &event::read()?) {\n                            InputAction::Continue => {},\n                            InputAction::Delete(index) => {\n                                if results.is_empty() {\n                                    break;\n                                }\n                                app.results_len -= 1;\n                                let selected = app.results_state.selected();\n                                if selected == app.results_len {\n                                    app.inspecting_state.reset();\n                                    app.results_state.select(selected - 1);\n                                }\n\n                                let entry = results.remove(index);\n\n                                if settings.sync.records {\n                                    let (id, _) = history_store.delete(entry.id).await?;\n                                    history_store.incremental_build(&db, &[id]).await?;\n                                } else {\n                                    db.delete(entry.clone()).await?;\n                                }\n\n                                app.tab_index  = 0;\n                            },\n                            InputAction::SwitchContext(index) => {\n                                if let Some(index) = index && let Some(entry) = results.get(index) {\n                                    app.search.custom_context = Some(entry.id.clone());\n                                    app.search.context = Context::from_history(entry);\n                                    app.search.filter_mode = FilterMode::Session;\n                                    app.search.input = Cursor::from(String::new());\n                                    app.results_state = ListState::default();\n                                } else {\n                                    app.search.custom_context = None;\n                                    app.search.context = initial_context.clone();\n                                    app.search.filter_mode = default_filter_mode;\n                                }\n                            },\n                            InputAction::Redraw => {\n                                if !popup_mode {\n                                    terminal.clear()?;\n                                }\n                                terminal.draw(|f| {\n                                    app.draw(f, &results, stats.clone(), inspecting.as_ref(), settings, theme, popup_mode);\n                                })?;\n                            },\n                            r => {\n                                accept = app.accept;\n                                break 'render r;\n                            },\n                        }\n                        if !event::poll(Duration::ZERO)? {\n                            break;\n                        }\n                    }\n                }\n            }\n            update_needed = &mut update_needed => {\n                // Don't fail interactive search if update check fails\n                // The update check is a nice-to-have feature, not critical\n                app.update_needed = update_needed.ok().flatten();\n            }\n        }\n\n        if initial_input != app.search.input.as_str()\n            || initial_filter_mode != app.search.filter_mode\n            || initial_search_mode != app.search_mode\n            || initial_custom_context != app.search.custom_context\n        {\n            results = app.query_results(&mut db, settings.smart_sort).await?;\n        }\n\n        // In custom context mode, when no filter is applied, highlight the entry which was used\n        // to enter the context when changing modes. This helps to find your way around.\n        if app.search.custom_context.is_some()\n            && app.search.input.as_str().is_empty()\n            && (initial_custom_context != app.search.custom_context\n                || initial_filter_mode != app.search.filter_mode)\n            && let Some(history_id) = app.search.custom_context.clone()\n            && let Some(pos) = results.iter().position(|entry| entry.id == history_id)\n        {\n            app.results_state.select(pos);\n        }\n\n        let inspecting_id = app.inspecting_state.clone().current;\n        // If inspecting ID is not the current inspecting History, update it.\n        match inspecting_id {\n            Some(inspecting_id) => {\n                if inspecting.is_none() || inspecting_id != inspecting.clone().unwrap().id {\n                    inspecting = db.load(inspecting_id.0.as_str()).await?;\n                }\n            }\n            _ => {\n                inspecting = None;\n            }\n        }\n\n        stats = if app.tab_index == 0 {\n            None\n        } else if !results.is_empty() {\n            // If we have stats, then we can indicate next available IDs. This avoids passing\n            // around a database object, or a full stats object.\n            let selected = match inspecting.clone() {\n                Some(insp) => insp,\n                None => results[app.results_state.selected()].clone(),\n            };\n            let stats = db.stats(&selected).await?;\n            app.inspecting_state.current = Some(selected.id);\n            app.inspecting_state.previous = match stats.previous.clone() {\n                Some(p) => Some(p.id),\n                _ => None,\n            };\n            app.inspecting_state.next = match stats.next.clone() {\n                Some(p) => Some(p.id),\n                _ => None,\n            };\n            Some(stats)\n        } else {\n            None\n        };\n    };\n\n    app.finalize_keymap_cursor(settings);\n\n    if popup_mode {\n        // In popup mode, restore the screen area that was covered by the popup.\n        // This must happen before Stdout is dropped (which disables raw mode).\n        #[cfg(unix)]\n        if let Some(ref saved) = saved_screen {\n            restore_popup_area(saved, popup_rect, popup_scroll_offset);\n        }\n    } else if inline_height > 0 {\n        terminal.clear()?;\n    }\n\n    let accept = accept\n        && matches!(\n            Shell::from_env(),\n            Shell::Zsh | Shell::Fish | Shell::Bash | Shell::Xonsh | Shell::Nu | Shell::Powershell\n        );\n\n    let accept_prefix = \"__atuin_accept__:\";\n\n    match result {\n        InputAction::AcceptInspecting => {\n            match inspecting {\n                Some(result) => {\n                    let mut command = result.command;\n\n                    if accept {\n                        command = String::from(accept_prefix) + &command;\n                    }\n\n                    // index is in bounds so we return that entry\n                    Ok(command)\n                }\n                None => Ok(String::new()),\n            }\n        }\n        InputAction::Accept(index) if index < results.len() => {\n            let mut command = results.swap_remove(index).command;\n\n            if is_command_chaining {\n                command = format!(\"{} {}\", original_query.trim_end(), command);\n            } else if accept {\n                command = String::from(accept_prefix) + &command;\n            }\n\n            // index is in bounds so we return that entry\n            Ok(command)\n        }\n        InputAction::ReturnOriginal => Ok(String::new()),\n        InputAction::Copy(index) => {\n            let cmd = results.swap_remove(index).command;\n            set_clipboard(cmd);\n            Ok(String::new())\n        }\n        InputAction::ReturnQuery | InputAction::Accept(_) => {\n            // Either:\n            // * index == RETURN_QUERY, in which case we should return the input\n            // * out of bounds -> usually implies no selected entry so we return the input\n            Ok(app.search.input.into_inner())\n        }\n        InputAction::Continue\n        | InputAction::Redraw\n        | InputAction::Delete(_)\n        | InputAction::SwitchContext(_) => {\n            unreachable!(\"should have been handled!\")\n        }\n    }\n}\n\n// cli-clipboard only works on Windows, Mac, and Linux.\n\n#[cfg(all(\n    feature = \"clipboard\",\n    any(target_os = \"windows\", target_os = \"macos\", target_os = \"linux\")\n))]\nfn set_clipboard(s: String) {\n    let mut ctx = arboard::Clipboard::new().unwrap();\n    ctx.set_text(s).unwrap();\n    // Use the clipboard context to make sure it is saved\n    ctx.get_text().unwrap();\n}\n\n#[cfg(not(all(\n    feature = \"clipboard\",\n    any(target_os = \"windows\", target_os = \"macos\", target_os = \"linux\")\n)))]\nfn set_clipboard(_s: String) {}\n\n#[cfg(test)]\nmod tests {\n    use atuin_client::database::Context;\n    use atuin_client::history::History;\n    use atuin_client::settings::{\n        FilterMode, KeymapMode, Preview, PreviewStrategy, SearchMode, Settings,\n    };\n    use time::OffsetDateTime;\n\n    use crate::command::client::search::engines::{self, SearchState};\n    use crate::command::client::search::history_list::ListState;\n\n    use super::{Compactness, InspectingState, KeymapSet, State};\n\n    #[test]\n    #[allow(clippy::too_many_lines)]\n    fn calc_preview_height_test() {\n        let settings_preview_auto = Settings {\n            preview: Preview {\n                strategy: PreviewStrategy::Auto,\n            },\n            show_preview: true,\n            ..Settings::utc()\n        };\n\n        let settings_preview_auto_h2 = Settings {\n            preview: Preview {\n                strategy: PreviewStrategy::Auto,\n            },\n            show_preview: true,\n            max_preview_height: 2,\n            ..Settings::utc()\n        };\n\n        let settings_preview_h4 = Settings {\n            preview: Preview {\n                strategy: PreviewStrategy::Static,\n            },\n            show_preview: true,\n            max_preview_height: 4,\n            ..Settings::utc()\n        };\n\n        let settings_preview_fixed = Settings {\n            preview: Preview {\n                strategy: PreviewStrategy::Fixed,\n            },\n            show_preview: true,\n            max_preview_height: 15,\n            ..Settings::utc()\n        };\n\n        let cmd_60: History = History::capture()\n            .timestamp(time::OffsetDateTime::now_utc())\n            .command(\"for i in $(seq -w 10); do echo \\\"item number $i - abcd\\\"; done\")\n            .cwd(\"/\")\n            .build()\n            .into();\n\n        let cmd_124: History = History::capture()\n            .timestamp(time::OffsetDateTime::now_utc())\n            .command(\"echo 'Aurea prima sata est aetas, quae vindice nullo, sponte sua, sine lege fidem rectumque colebat. Poena metusque aberant'\")\n            .cwd(\"/\")\n            .build()\n            .into();\n\n        let cmd_200: History = History::capture()\n            .timestamp(time::OffsetDateTime::now_utc())\n            .command(\"CREATE USER atuin WITH ENCRYPTED PASSWORD 'supersecretpassword'; CREATE DATABASE atuin WITH OWNER = atuin; \\\\c atuin; REVOKE ALL PRIVILEGES ON SCHEMA public FROM PUBLIC; echo 'All done. 200 characters'\")\n            .cwd(\"/\")\n            .build()\n            .into();\n\n        let results: Vec<History> = vec![cmd_60, cmd_124, cmd_200];\n\n        // the selected command does not require a preview\n        let no_preview = State::calc_preview_height(\n            &settings_preview_auto,\n            &results,\n            0_usize,\n            0_usize,\n            Compactness::Full,\n            1,\n            80,\n        );\n        // the selected command requires 2 lines\n        let preview_h2 = State::calc_preview_height(\n            &settings_preview_auto,\n            &results,\n            1_usize,\n            0_usize,\n            Compactness::Full,\n            1,\n            80,\n        );\n        // the selected command requires 3 lines\n        let preview_h3 = State::calc_preview_height(\n            &settings_preview_auto,\n            &results,\n            2_usize,\n            0_usize,\n            Compactness::Full,\n            1,\n            80,\n        );\n        // the selected command requires a preview of 1 line (happens when the command is between preview_width-19 and preview_width)\n        let preview_one_line = State::calc_preview_height(\n            &settings_preview_auto,\n            &results,\n            0_usize,\n            0_usize,\n            Compactness::Full,\n            1,\n            66,\n        );\n        // the selected command requires 3 lines, but we have a max preview height limit of 2\n        let preview_limit_at_2 = State::calc_preview_height(\n            &settings_preview_auto_h2,\n            &results,\n            2_usize,\n            0_usize,\n            Compactness::Full,\n            1,\n            80,\n        );\n        // the longest command requires 3 lines\n        let preview_static_h3 = State::calc_preview_height(\n            &settings_preview_h4,\n            &results,\n            1_usize,\n            0_usize,\n            Compactness::Full,\n            1,\n            80,\n        );\n        // the longest command requires 10 lines, but we have a max preview height limit of 4\n        let preview_static_limit_at_4 = State::calc_preview_height(\n            &settings_preview_h4,\n            &results,\n            1_usize,\n            0_usize,\n            Compactness::Full,\n            1,\n            20,\n        );\n        // the longest command requires 10 lines, but we have a max preview height of 15 and a fixed preview strategy\n        let settings_preview_fixed = State::calc_preview_height(\n            &settings_preview_fixed,\n            &results,\n            1_usize,\n            0_usize,\n            Compactness::Full,\n            1,\n            20,\n        );\n\n        assert_eq!(no_preview, 1);\n        // 1 * 2 is the space for the border\n        let border_space = 2;\n        assert_eq!(preview_h2, 2 + border_space);\n        assert_eq!(preview_h3, 3 + border_space);\n        assert_eq!(preview_one_line, 1 + border_space);\n        assert_eq!(preview_limit_at_2, 2 + border_space);\n        assert_eq!(preview_static_h3, 3 + border_space);\n        assert_eq!(preview_static_limit_at_4, 4 + border_space);\n        assert_eq!(settings_preview_fixed, 15 + border_space);\n    }\n\n    // Test when there's no results, scrolling up or down doesn't underflow\n    #[test]\n    fn state_scroll_up_underflow() {\n        let settings = Settings::utc();\n        let mut state = State {\n            history_count: 0,\n            update_needed: None,\n            results_state: ListState::default(),\n            switched_search_mode: false,\n            search_mode: SearchMode::Fuzzy,\n            results_len: 0,\n            accept: false,\n            keymap_mode: KeymapMode::Auto,\n            prefix: false,\n            current_cursor: None,\n            tab_index: 0,\n            pending_vim_key: None,\n            original_input_empty: false,\n            inspecting_state: InspectingState {\n                current: None,\n                next: None,\n                previous: None,\n            },\n            keymaps: KeymapSet::defaults(&settings),\n            search: SearchState {\n                input: String::new().into(),\n                filter_mode: FilterMode::Directory,\n                context: Context {\n                    session: String::new(),\n                    cwd: String::new(),\n                    hostname: String::new(),\n                    host_id: String::new(),\n                    git_root: None,\n                },\n                custom_context: None,\n            },\n            engine: engines::engine(SearchMode::Fuzzy, &settings),\n            now: Box::new(OffsetDateTime::now_utc),\n        };\n\n        state.scroll_up(1);\n        state.scroll_down(1);\n    }\n\n    #[test]\n    fn test_accept_keybindings() {\n        use atuin_client::settings::Keys;\n        use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};\n\n        let mut settings = Settings::utc();\n        settings.keys = Keys {\n            scroll_exits: true,\n            exit_past_line_start: false,\n            accept_past_line_end: true,\n            accept_past_line_start: false,\n            accept_with_backspace: false,\n            prefix: \"a\".to_string(),\n        };\n\n        let mut state = State {\n            history_count: 1,\n            update_needed: None,\n            results_state: ListState::default(),\n            switched_search_mode: false,\n            search_mode: SearchMode::Fuzzy,\n            results_len: 1,\n            accept: false,\n            keymap_mode: KeymapMode::Emacs,\n            prefix: false,\n            current_cursor: None,\n            tab_index: 0,\n            pending_vim_key: None,\n            original_input_empty: false,\n            inspecting_state: InspectingState {\n                current: None,\n                next: None,\n                previous: None,\n            },\n            keymaps: KeymapSet::defaults(&settings),\n            search: SearchState {\n                input: String::new().into(),\n                filter_mode: FilterMode::Global,\n                context: Context {\n                    session: String::new(),\n                    cwd: String::new(),\n                    hostname: String::new(),\n                    host_id: String::new(),\n                    git_root: None,\n                },\n                custom_context: None,\n            },\n            engine: engines::engine(SearchMode::Fuzzy, &settings),\n            now: Box::new(OffsetDateTime::now_utc),\n        };\n\n        let tab_event = KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE);\n        let result = state.handle_key_input(&settings, &tab_event);\n        assert!(\n            matches!(result, super::InputAction::Accept(_)),\n            \"Tab should always accept\"\n        );\n\n        // Test left arrow with accept_past_line_start disabled (should continue)\n        let left_event = KeyEvent::new(KeyCode::Left, KeyModifiers::NONE);\n        let result = state.handle_key_input(&settings, &left_event);\n        assert!(\n            matches!(result, super::InputAction::Continue),\n            \"Left arrow should continue when disabled\"\n        );\n\n        // Test left arrow with accept_past_line_start enabled (should accept at start of line)\n        settings.keys.accept_past_line_start = true;\n        state.keymaps = KeymapSet::defaults(&settings);\n        let result = state.handle_key_input(&settings, &left_event);\n        assert!(\n            matches!(result, super::InputAction::Accept(_)),\n            \"Left arrow should accept at start of line when enabled\"\n        );\n        settings.keys.accept_past_line_start = false;\n        state.keymaps = KeymapSet::defaults(&settings);\n\n        let backspace_event = KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE);\n        let result = state.handle_key_input(&settings, &backspace_event);\n        assert!(\n            matches!(result, super::InputAction::Continue),\n            \"Backspace should continue when disabled\"\n        );\n\n        settings.keys.accept_with_backspace = true;\n        state.keymaps = KeymapSet::defaults(&settings);\n        let result = state.handle_key_input(&settings, &backspace_event);\n        assert!(\n            matches!(result, super::InputAction::Accept(_)),\n            \"Backspace should accept at start of line when enabled\"\n        );\n\n        state.search.input.insert('t');\n        state.search.input.insert('e');\n        state.search.input.insert('s');\n        state.search.input.insert('t');\n        state.search.input.end();\n\n        let right_event = KeyEvent::new(KeyCode::Right, KeyModifiers::NONE);\n        let result = state.handle_key_input(&settings, &right_event);\n        assert!(\n            matches!(result, super::InputAction::Accept(_)),\n            \"Right arrow should accept at end of line when enabled\"\n        );\n\n        settings.keys.accept_past_line_start = true;\n        state.keymaps = KeymapSet::defaults(&settings);\n        let left_event = KeyEvent::new(KeyCode::Left, KeyModifiers::NONE);\n        let result = state.handle_key_input(&settings, &left_event);\n        assert!(\n            matches!(result, super::InputAction::Continue),\n            \"Left arrow should continue and end of line, even when enabled\"\n        );\n        settings.keys.accept_past_line_start = false;\n        state.keymaps = KeymapSet::defaults(&settings);\n\n        settings.keys.accept_with_backspace = true;\n        state.keymaps = KeymapSet::defaults(&settings);\n        let backspace_event = KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE);\n        let result = state.handle_key_input(&settings, &backspace_event);\n        assert!(\n            matches!(result, super::InputAction::Continue),\n            \"Backspace should continue at end of line, even when enabled\"\n        );\n        settings.keys.accept_with_backspace = false;\n        state.keymaps = KeymapSet::defaults(&settings);\n    }\n\n    #[test]\n    fn test_vim_gg_multikey_sequence() {\n        use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};\n\n        let settings = Settings::utc();\n\n        let mut state = State {\n            history_count: 100,\n            update_needed: None,\n            results_state: ListState::default(),\n            switched_search_mode: false,\n            search_mode: SearchMode::Fuzzy,\n            results_len: 100,\n            accept: false,\n            keymap_mode: KeymapMode::VimNormal,\n            prefix: false,\n            current_cursor: None,\n            tab_index: 0,\n            pending_vim_key: None,\n            original_input_empty: false,\n            inspecting_state: InspectingState {\n                current: None,\n                next: None,\n                previous: None,\n            },\n            keymaps: KeymapSet::defaults(&settings),\n            search: SearchState {\n                input: String::new().into(),\n                filter_mode: FilterMode::Global,\n                context: Context {\n                    session: String::new(),\n                    cwd: String::new(),\n                    hostname: String::new(),\n                    host_id: String::new(),\n                    git_root: None,\n                },\n                custom_context: None,\n            },\n            engine: engines::engine(SearchMode::Fuzzy, &settings),\n            now: Box::new(OffsetDateTime::now_utc),\n        };\n\n        // Start in the middle of the list\n        state.results_state.select(50);\n\n        // First 'g' should set pending state\n        let g_event = KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE);\n        let result = state.handle_key_input(&settings, &g_event);\n        assert!(matches!(result, super::InputAction::Continue));\n        assert_eq!(state.pending_vim_key, Some('g'));\n        assert_eq!(state.results_state.selected(), 50); // Position unchanged\n\n        // Second 'g' should jump to end (visual top in non-inverted mode)\n        let result = state.handle_key_input(&settings, &g_event);\n        assert!(matches!(result, super::InputAction::Continue));\n        assert_eq!(state.pending_vim_key, None);\n        assert_eq!(state.results_state.selected(), 99); // Jumped to last index (visual top)\n    }\n\n    #[test]\n    fn test_vim_g_key_clears_on_other_input() {\n        use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};\n\n        let settings = Settings::utc();\n\n        let mut state = State {\n            history_count: 100,\n            update_needed: None,\n            results_state: ListState::default(),\n            switched_search_mode: false,\n            search_mode: SearchMode::Fuzzy,\n            results_len: 100,\n            accept: false,\n            keymap_mode: KeymapMode::VimNormal,\n            prefix: false,\n            current_cursor: None,\n            tab_index: 0,\n            pending_vim_key: None,\n            original_input_empty: false,\n            inspecting_state: InspectingState {\n                current: None,\n                next: None,\n                previous: None,\n            },\n            keymaps: KeymapSet::defaults(&settings),\n            search: SearchState {\n                input: String::new().into(),\n                filter_mode: FilterMode::Global,\n                context: Context {\n                    session: String::new(),\n                    cwd: String::new(),\n                    hostname: String::new(),\n                    host_id: String::new(),\n                    git_root: None,\n                },\n                custom_context: None,\n            },\n            engine: engines::engine(SearchMode::Fuzzy, &settings),\n            now: Box::new(OffsetDateTime::now_utc),\n        };\n\n        state.results_state.select(50);\n\n        // Press 'g' to set pending state\n        let g_event = KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE);\n        state.handle_key_input(&settings, &g_event);\n        assert_eq!(state.pending_vim_key, Some('g'));\n\n        // Press 'j' - should clear pending state\n        let j_event = KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE);\n        state.handle_key_input(&settings, &j_event);\n        assert_eq!(state.pending_vim_key, None);\n    }\n\n    #[test]\n    fn test_vim_big_g_jump_to_bottom() {\n        use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};\n\n        let settings = Settings::utc();\n\n        let mut state = State {\n            history_count: 100,\n            update_needed: None,\n            results_state: ListState::default(),\n            switched_search_mode: false,\n            search_mode: SearchMode::Fuzzy,\n            results_len: 100,\n            accept: false,\n            keymap_mode: KeymapMode::VimNormal,\n            prefix: false,\n            current_cursor: None,\n            tab_index: 0,\n            pending_vim_key: None,\n            original_input_empty: false,\n            inspecting_state: InspectingState {\n                current: None,\n                next: None,\n                previous: None,\n            },\n            keymaps: KeymapSet::defaults(&settings),\n            search: SearchState {\n                input: String::new().into(),\n                filter_mode: FilterMode::Global,\n                context: Context {\n                    session: String::new(),\n                    cwd: String::new(),\n                    hostname: String::new(),\n                    host_id: String::new(),\n                    git_root: None,\n                },\n                custom_context: None,\n            },\n            engine: engines::engine(SearchMode::Fuzzy, &settings),\n            now: Box::new(OffsetDateTime::now_utc),\n        };\n\n        state.results_state.select(50);\n\n        // 'G' should jump to visual bottom (index 0 in non-inverted mode)\n        let big_g_event = KeyEvent::new(KeyCode::Char('G'), KeyModifiers::NONE);\n        let result = state.handle_key_input(&settings, &big_g_event);\n        assert!(matches!(result, super::InputAction::Continue));\n        assert_eq!(state.results_state.selected(), 0);\n    }\n\n    #[test]\n    fn test_vim_ctrl_u_d_half_page_scroll() {\n        use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};\n\n        let settings = Settings::utc();\n\n        let mut state = State {\n            history_count: 100,\n            update_needed: None,\n            results_state: ListState::default(),\n            switched_search_mode: false,\n            search_mode: SearchMode::Fuzzy,\n            results_len: 100,\n            accept: false,\n            keymap_mode: KeymapMode::VimNormal,\n            prefix: false,\n            current_cursor: None,\n            tab_index: 0,\n            pending_vim_key: None,\n            original_input_empty: false,\n            inspecting_state: InspectingState {\n                current: None,\n                next: None,\n                previous: None,\n            },\n            keymaps: KeymapSet::defaults(&settings),\n            search: SearchState {\n                input: String::new().into(),\n                filter_mode: FilterMode::Global,\n                context: Context {\n                    session: String::new(),\n                    cwd: String::new(),\n                    hostname: String::new(),\n                    host_id: String::new(),\n                    git_root: None,\n                },\n                custom_context: None,\n            },\n            engine: engines::engine(SearchMode::Fuzzy, &settings),\n            now: Box::new(OffsetDateTime::now_utc),\n        };\n\n        state.results_state.select(50);\n\n        // Ctrl+d should return Continue and clear pending key\n        // (scroll amount depends on max_entries which is 0 in tests)\n        state.pending_vim_key = Some('g');\n        let ctrl_d_event = KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL);\n        let result = state.handle_key_input(&settings, &ctrl_d_event);\n        assert!(matches!(result, super::InputAction::Continue));\n        assert_eq!(state.pending_vim_key, None);\n\n        // Ctrl+u should return Continue and clear pending key\n        state.pending_vim_key = Some('g');\n        let ctrl_u_event = KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL);\n        let result = state.handle_key_input(&settings, &ctrl_u_event);\n        assert!(matches!(result, super::InputAction::Continue));\n        assert_eq!(state.pending_vim_key, None);\n    }\n\n    #[test]\n    fn test_vim_ctrl_f_b_full_page_scroll() {\n        use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};\n\n        let settings = Settings::utc();\n\n        let mut state = State {\n            history_count: 100,\n            update_needed: None,\n            results_state: ListState::default(),\n            switched_search_mode: false,\n            search_mode: SearchMode::Fuzzy,\n            results_len: 100,\n            accept: false,\n            keymap_mode: KeymapMode::VimNormal,\n            prefix: false,\n            current_cursor: None,\n            tab_index: 0,\n            pending_vim_key: None,\n            original_input_empty: false,\n            inspecting_state: InspectingState {\n                current: None,\n                next: None,\n                previous: None,\n            },\n            keymaps: KeymapSet::defaults(&settings),\n            search: SearchState {\n                input: String::new().into(),\n                filter_mode: FilterMode::Global,\n                context: Context {\n                    session: String::new(),\n                    cwd: String::new(),\n                    hostname: String::new(),\n                    host_id: String::new(),\n                    git_root: None,\n                },\n                custom_context: None,\n            },\n            engine: engines::engine(SearchMode::Fuzzy, &settings),\n            now: Box::new(OffsetDateTime::now_utc),\n        };\n\n        state.results_state.select(50);\n\n        // Ctrl+f should return Continue and clear pending key\n        // (scroll amount depends on max_entries which is 0 in tests)\n        state.pending_vim_key = Some('g');\n        let ctrl_f_event = KeyEvent::new(KeyCode::Char('f'), KeyModifiers::CONTROL);\n        let result = state.handle_key_input(&settings, &ctrl_f_event);\n        assert!(matches!(result, super::InputAction::Continue));\n        assert_eq!(state.pending_vim_key, None);\n\n        // Ctrl+b should return Continue and clear pending key\n        state.pending_vim_key = Some('g');\n        let ctrl_b_event = KeyEvent::new(KeyCode::Char('b'), KeyModifiers::CONTROL);\n        let result = state.handle_key_input(&settings, &ctrl_b_event);\n        assert!(matches!(result, super::InputAction::Continue));\n        assert_eq!(state.pending_vim_key, None);\n    }\n\n    // -----------------------------------------------------------------------\n    // Executor tests (execute_action)\n    // -----------------------------------------------------------------------\n\n    /// Helper to build a State for executor tests.\n    fn make_executor_state(results_len: usize, selected: usize) -> State {\n        let settings = Settings::utc();\n        let mut state = State {\n            history_count: results_len as i64,\n            update_needed: None,\n            results_state: ListState::default(),\n            switched_search_mode: false,\n            search_mode: SearchMode::Fuzzy,\n            results_len,\n            accept: false,\n            keymap_mode: KeymapMode::Emacs,\n            prefix: false,\n            current_cursor: None,\n            tab_index: 0,\n            pending_vim_key: None,\n            original_input_empty: false,\n            inspecting_state: InspectingState {\n                current: None,\n                next: None,\n                previous: None,\n            },\n            keymaps: KeymapSet::defaults(&settings),\n            search: SearchState {\n                input: String::new().into(),\n                filter_mode: FilterMode::Global,\n                context: Context {\n                    session: String::new(),\n                    cwd: String::new(),\n                    hostname: String::new(),\n                    host_id: String::new(),\n                    git_root: None,\n                },\n                custom_context: None,\n            },\n            engine: engines::engine(SearchMode::Fuzzy, &settings),\n            now: Box::new(OffsetDateTime::now_utc),\n        };\n        state.results_state.select(selected);\n        state\n    }\n\n    #[test]\n    fn execute_select_next_no_invert() {\n        use crate::command::client::search::keybindings::Action;\n\n        let mut state = make_executor_state(100, 50);\n        let settings = Settings::utc();\n        let result = state.execute_action(&Action::SelectNext, &settings);\n        assert!(matches!(result, super::InputAction::Continue));\n        // Non-inverted: SelectNext = scroll_down = selected - 1\n        assert_eq!(state.results_state.selected(), 49);\n    }\n\n    #[test]\n    fn execute_select_next_with_invert() {\n        use crate::command::client::search::keybindings::Action;\n\n        let mut state = make_executor_state(100, 50);\n        let mut settings = Settings::utc();\n        settings.invert = true;\n        let result = state.execute_action(&Action::SelectNext, &settings);\n        assert!(matches!(result, super::InputAction::Continue));\n        // Inverted: SelectNext = scroll_up = selected + 1\n        assert_eq!(state.results_state.selected(), 51);\n    }\n\n    #[test]\n    fn execute_select_previous_no_invert() {\n        use crate::command::client::search::keybindings::Action;\n\n        let mut state = make_executor_state(100, 50);\n        let settings = Settings::utc();\n        let result = state.execute_action(&Action::SelectPrevious, &settings);\n        assert!(matches!(result, super::InputAction::Continue));\n        // Non-inverted: SelectPrevious = scroll_up = selected + 1\n        assert_eq!(state.results_state.selected(), 51);\n    }\n\n    #[test]\n    fn execute_vim_enter_normal() {\n        use crate::command::client::search::keybindings::Action;\n\n        let mut state = make_executor_state(100, 0);\n        let settings = Settings::utc();\n        let result = state.execute_action(&Action::VimEnterNormal, &settings);\n        assert!(matches!(result, super::InputAction::Continue));\n        assert_eq!(state.keymap_mode, KeymapMode::VimNormal);\n    }\n\n    #[test]\n    fn execute_vim_enter_insert() {\n        use crate::command::client::search::keybindings::Action;\n\n        let mut state = make_executor_state(100, 0);\n        state.keymap_mode = KeymapMode::VimNormal;\n        let settings = Settings::utc();\n        let result = state.execute_action(&Action::VimEnterInsert, &settings);\n        assert!(matches!(result, super::InputAction::Continue));\n        assert_eq!(state.keymap_mode, KeymapMode::VimInsert);\n    }\n\n    #[test]\n    fn execute_accept_sets_accept_flag() {\n        use crate::command::client::search::keybindings::Action;\n\n        let mut state = make_executor_state(100, 5);\n        let mut settings = Settings::utc();\n        settings.enter_accept = true;\n        let result = state.execute_action(&Action::Accept, &settings);\n        assert!(matches!(result, super::InputAction::Accept(5)));\n        assert!(state.accept);\n    }\n\n    #[test]\n    fn execute_return_selection_does_not_set_accept() {\n        use crate::command::client::search::keybindings::Action;\n\n        let mut state = make_executor_state(100, 5);\n        let settings = Settings::utc();\n        let result = state.execute_action(&Action::ReturnSelection, &settings);\n        assert!(matches!(result, super::InputAction::Accept(5)));\n        assert!(!state.accept);\n    }\n\n    #[test]\n    fn execute_accept_nth() {\n        use crate::command::client::search::keybindings::Action;\n\n        let mut state = make_executor_state(100, 5);\n        let settings = Settings::utc();\n        let result = state.execute_action(&Action::AcceptNth(3), &settings);\n        assert!(matches!(result, super::InputAction::Accept(8)));\n    }\n\n    #[test]\n    fn execute_scroll_to_top_no_invert() {\n        use crate::command::client::search::keybindings::Action;\n\n        let mut state = make_executor_state(100, 50);\n        let settings = Settings::utc();\n        let result = state.execute_action(&Action::ScrollToTop, &settings);\n        assert!(matches!(result, super::InputAction::Continue));\n        // Non-inverted: visual top = highest index\n        assert_eq!(state.results_state.selected(), 99);\n    }\n\n    #[test]\n    fn execute_scroll_to_top_with_invert() {\n        use crate::command::client::search::keybindings::Action;\n\n        let mut state = make_executor_state(100, 50);\n        let mut settings = Settings::utc();\n        settings.invert = true;\n        let result = state.execute_action(&Action::ScrollToTop, &settings);\n        assert!(matches!(result, super::InputAction::Continue));\n        // Inverted: visual top = index 0\n        assert_eq!(state.results_state.selected(), 0);\n    }\n\n    #[test]\n    fn execute_scroll_to_bottom_no_invert() {\n        use crate::command::client::search::keybindings::Action;\n\n        let mut state = make_executor_state(100, 50);\n        let settings = Settings::utc();\n        let result = state.execute_action(&Action::ScrollToBottom, &settings);\n        assert!(matches!(result, super::InputAction::Continue));\n        // Non-inverted: visual bottom = index 0\n        assert_eq!(state.results_state.selected(), 0);\n    }\n\n    #[test]\n    fn execute_toggle_tab() {\n        use crate::command::client::search::keybindings::Action;\n\n        let mut state = make_executor_state(100, 0);\n        let settings = Settings::utc();\n        assert_eq!(state.tab_index, 0);\n        state.execute_action(&Action::ToggleTab, &settings);\n        assert_eq!(state.tab_index, 1);\n        state.execute_action(&Action::ToggleTab, &settings);\n        assert_eq!(state.tab_index, 0);\n    }\n\n    #[test]\n    fn execute_enter_prefix_mode() {\n        use crate::command::client::search::keybindings::Action;\n\n        let mut state = make_executor_state(100, 0);\n        let settings = Settings::utc();\n        assert!(!state.prefix);\n        state.execute_action(&Action::EnterPrefixMode, &settings);\n        assert!(state.prefix);\n    }\n\n    #[test]\n    fn execute_exit_returns_based_on_exit_mode() {\n        use crate::command::client::search::keybindings::Action;\n        use atuin_client::settings::ExitMode;\n\n        let mut state = make_executor_state(100, 0);\n        let mut settings = Settings::utc();\n\n        settings.exit_mode = ExitMode::ReturnOriginal;\n        let result = state.execute_action(&Action::Exit, &settings);\n        assert!(matches!(result, super::InputAction::ReturnOriginal));\n\n        settings.exit_mode = ExitMode::ReturnQuery;\n        let result = state.execute_action(&Action::Exit, &settings);\n        assert!(matches!(result, super::InputAction::ReturnQuery));\n    }\n\n    #[test]\n    fn execute_return_original() {\n        use crate::command::client::search::keybindings::Action;\n\n        let mut state = make_executor_state(100, 0);\n        let settings = Settings::utc();\n        let result = state.execute_action(&Action::ReturnOriginal, &settings);\n        assert!(matches!(result, super::InputAction::ReturnOriginal));\n    }\n\n    #[test]\n    fn execute_copy() {\n        use crate::command::client::search::keybindings::Action;\n\n        let mut state = make_executor_state(100, 7);\n        let settings = Settings::utc();\n        let result = state.execute_action(&Action::Copy, &settings);\n        assert!(matches!(result, super::InputAction::Copy(7)));\n    }\n\n    #[test]\n    fn execute_delete() {\n        use crate::command::client::search::keybindings::Action;\n\n        let mut state = make_executor_state(100, 7);\n        let settings = Settings::utc();\n        let result = state.execute_action(&Action::Delete, &settings);\n        assert!(matches!(result, super::InputAction::Delete(7)));\n    }\n\n    #[test]\n    fn execute_switch_context() {\n        use crate::command::client::search::keybindings::Action;\n\n        let mut state = make_executor_state(100, 7);\n        let settings = Settings::utc();\n        let result = state.execute_action(&Action::SwitchContext, &settings);\n        assert!(matches!(result, super::InputAction::SwitchContext(Some(7))));\n    }\n\n    #[test]\n    fn execute_clear_context() {\n        use crate::command::client::search::keybindings::Action;\n\n        let mut state = make_executor_state(100, 7);\n        let settings = Settings::utc();\n        let result = state.execute_action(&Action::ClearContext, &settings);\n        assert!(matches!(result, super::InputAction::SwitchContext(None)));\n    }\n\n    #[test]\n    fn execute_noop() {\n        use crate::command::client::search::keybindings::Action;\n\n        let mut state = make_executor_state(100, 50);\n        let settings = Settings::utc();\n        let result = state.execute_action(&Action::Noop, &settings);\n        assert!(matches!(result, super::InputAction::Continue));\n        assert_eq!(state.results_state.selected(), 50);\n    }\n\n    #[test]\n    fn execute_accept_in_inspector_tab() {\n        use crate::command::client::search::keybindings::Action;\n\n        let mut state = make_executor_state(100, 5);\n        state.tab_index = 1;\n        let settings = Settings::utc();\n        let result = state.execute_action(&Action::Accept, &settings);\n        assert!(matches!(result, super::InputAction::AcceptInspecting));\n    }\n\n    #[test]\n    fn execute_cycle_search_mode() {\n        use crate::command::client::search::keybindings::Action;\n\n        let mut state = make_executor_state(100, 0);\n        let settings = Settings::utc();\n        let original_mode = state.search_mode;\n        let result = state.execute_action(&Action::CycleSearchMode, &settings);\n        assert!(matches!(result, super::InputAction::Continue));\n        assert!(state.switched_search_mode);\n        assert_ne!(state.search_mode, original_mode);\n    }\n\n    #[test]\n    fn execute_vim_search_insert() {\n        use crate::command::client::search::keybindings::Action;\n\n        let mut state = make_executor_state(100, 0);\n        state.search.input.insert('h');\n        state.search.input.insert('i');\n        state.keymap_mode = KeymapMode::VimNormal;\n        let settings = Settings::utc();\n        let result = state.execute_action(&Action::VimSearchInsert, &settings);\n        assert!(matches!(result, super::InputAction::Continue));\n        // Should clear input and switch to insert mode\n        assert_eq!(state.search.input.as_str(), \"\");\n        assert_eq!(state.keymap_mode, KeymapMode::VimInsert);\n    }\n\n    #[test]\n    fn execute_cursor_movement() {\n        use crate::command::client::search::keybindings::Action;\n\n        let mut state = make_executor_state(100, 0);\n        let settings = Settings::utc();\n\n        // Insert some text\n        state.search.input.insert('h');\n        state.search.input.insert('e');\n        state.search.input.insert('l');\n        state.search.input.insert('l');\n        state.search.input.insert('o');\n        // cursor is at end (position 5)\n\n        // CursorLeft\n        state.execute_action(&Action::CursorLeft, &settings);\n        assert_eq!(state.search.input.position(), 4);\n\n        // CursorStart\n        state.execute_action(&Action::CursorStart, &settings);\n        assert_eq!(state.search.input.position(), 0);\n\n        // CursorEnd\n        state.execute_action(&Action::CursorEnd, &settings);\n        assert_eq!(state.search.input.position(), 5);\n\n        // CursorRight at end does nothing\n        state.execute_action(&Action::CursorRight, &settings);\n        assert_eq!(state.search.input.position(), 5);\n    }\n\n    #[test]\n    fn execute_editing() {\n        use crate::command::client::search::keybindings::Action;\n\n        let mut state = make_executor_state(100, 0);\n        let settings = Settings::utc();\n\n        // Insert \"hello\"\n        state.search.input.insert('h');\n        state.search.input.insert('e');\n        state.search.input.insert('l');\n        state.search.input.insert('l');\n        state.search.input.insert('o');\n\n        // DeleteCharBefore (backspace)\n        state.execute_action(&Action::DeleteCharBefore, &settings);\n        assert_eq!(state.search.input.as_str(), \"hell\");\n\n        // ClearLine\n        state.execute_action(&Action::ClearLine, &settings);\n        assert_eq!(state.search.input.as_str(), \"\");\n    }\n\n    #[test]\n    fn keymap_config_return_query() {\n        use atuin_client::settings::KeyBindingConfig;\n        use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};\n        use std::collections::HashMap;\n\n        let mut settings = Settings::utc();\n        // Configure tab to return-query\n        settings.keymap.emacs = HashMap::from([(\n            \"tab\".to_string(),\n            KeyBindingConfig::Simple(\"return-query\".to_string()),\n        )]);\n\n        let mut state = State {\n            history_count: 100,\n            update_needed: None,\n            results_state: ListState::default(),\n            switched_search_mode: false,\n            search_mode: SearchMode::Fuzzy,\n            results_len: 100,\n            accept: false,\n            keymap_mode: KeymapMode::Emacs,\n            prefix: false,\n            current_cursor: None,\n            tab_index: 0,\n            pending_vim_key: None,\n            original_input_empty: false,\n            inspecting_state: InspectingState {\n                current: None,\n                next: None,\n                previous: None,\n            },\n            keymaps: KeymapSet::from_settings(&settings),\n            search: SearchState {\n                input: \"test query\".to_string().into(),\n                filter_mode: FilterMode::Global,\n                context: Context {\n                    session: String::new(),\n                    cwd: String::new(),\n                    hostname: String::new(),\n                    host_id: String::new(),\n                    git_root: None,\n                },\n                custom_context: None,\n            },\n            engine: engines::engine(SearchMode::Fuzzy, &settings),\n            now: Box::new(OffsetDateTime::now_utc),\n        };\n\n        let tab_event = KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE);\n        let result = state.handle_key_input(&settings, &tab_event);\n        assert!(\n            matches!(result, super::InputAction::ReturnQuery),\n            \"Tab configured as return-query should return InputAction::ReturnQuery\"\n        );\n    }\n}\n"
  },
  {
    "path": "crates/atuin/src/command/client/search/keybindings/actions.rs",
    "content": "use std::fmt;\n\nuse serde::{Deserialize, Deserializer, Serialize, Serializer};\n\n/// All possible actions that can be triggered by a keybinding.\n#[derive(Debug, Clone, PartialEq, Eq)]\npub enum Action {\n    // Cursor movement\n    CursorLeft,\n    CursorRight,\n    CursorWordLeft,\n    CursorWordRight,\n    CursorWordEnd,\n    CursorStart,\n    CursorEnd,\n\n    // Editing\n    DeleteCharBefore,\n    DeleteCharAfter,\n    DeleteWordBefore,\n    DeleteWordAfter,\n    DeleteToWordBoundary,\n    ClearLine,\n    ClearToStart,\n    ClearToEnd,\n\n    // List navigation\n    SelectNext,\n    SelectPrevious,\n    ScrollHalfPageUp,\n    ScrollHalfPageDown,\n    ScrollPageUp,\n    ScrollPageDown,\n    ScrollToTop,\n    ScrollToBottom,\n    ScrollToScreenTop,\n    ScrollToScreenMiddle,\n    ScrollToScreenBottom,\n\n    // Commands — accept selection and execute immediately\n    Accept,\n    AcceptNth(u8),\n    // Commands — return selection to command line without executing\n    ReturnSelection,\n    ReturnSelectionNth(u8),\n    // Commands — other\n    Copy,\n    Delete,\n    ReturnOriginal,\n    ReturnQuery,\n    Exit,\n    Redraw,\n    CycleFilterMode,\n    CycleSearchMode,\n    SwitchContext,\n    ClearContext,\n    ToggleTab,\n\n    // Mode changes\n    VimEnterNormal,\n    VimEnterInsert,\n    VimEnterInsertAfter,\n    VimEnterInsertAtStart,\n    VimEnterInsertAtEnd,\n    VimSearchInsert,\n    VimChangeToEnd,\n    EnterPrefixMode,\n\n    // Inspector\n    InspectPrevious,\n    InspectNext,\n\n    // Special\n    Noop,\n}\n\nimpl Action {\n    /// Convert from a kebab-case string.\n    pub fn from_str(s: &str) -> Result<Self, String> {\n        // Handle accept-N and return-selection-N patterns\n        if let Some(rest) = s.strip_prefix(\"accept-\")\n            && let Ok(n) = rest.parse::<u8>()\n            && (1..=9).contains(&n)\n        {\n            return Ok(Action::AcceptNth(n));\n        }\n        if let Some(rest) = s.strip_prefix(\"return-selection-\")\n            && let Ok(n) = rest.parse::<u8>()\n            && (1..=9).contains(&n)\n        {\n            return Ok(Action::ReturnSelectionNth(n));\n        }\n\n        match s {\n            \"cursor-left\" => Ok(Action::CursorLeft),\n            \"cursor-right\" => Ok(Action::CursorRight),\n            \"cursor-word-left\" => Ok(Action::CursorWordLeft),\n            \"cursor-word-right\" => Ok(Action::CursorWordRight),\n            \"cursor-word-end\" => Ok(Action::CursorWordEnd),\n            \"cursor-start\" => Ok(Action::CursorStart),\n            \"cursor-end\" => Ok(Action::CursorEnd),\n\n            \"delete-char-before\" => Ok(Action::DeleteCharBefore),\n            \"delete-char-after\" => Ok(Action::DeleteCharAfter),\n            \"delete-word-before\" => Ok(Action::DeleteWordBefore),\n            \"delete-word-after\" => Ok(Action::DeleteWordAfter),\n            \"delete-to-word-boundary\" => Ok(Action::DeleteToWordBoundary),\n            \"clear-line\" => Ok(Action::ClearLine),\n            \"clear-to-start\" => Ok(Action::ClearToStart),\n            \"clear-to-end\" => Ok(Action::ClearToEnd),\n\n            \"select-next\" => Ok(Action::SelectNext),\n            \"select-previous\" => Ok(Action::SelectPrevious),\n            \"scroll-half-page-up\" => Ok(Action::ScrollHalfPageUp),\n            \"scroll-half-page-down\" => Ok(Action::ScrollHalfPageDown),\n            \"scroll-page-up\" => Ok(Action::ScrollPageUp),\n            \"scroll-page-down\" => Ok(Action::ScrollPageDown),\n            \"scroll-to-top\" => Ok(Action::ScrollToTop),\n            \"scroll-to-bottom\" => Ok(Action::ScrollToBottom),\n            \"scroll-to-screen-top\" => Ok(Action::ScrollToScreenTop),\n            \"scroll-to-screen-middle\" => Ok(Action::ScrollToScreenMiddle),\n            \"scroll-to-screen-bottom\" => Ok(Action::ScrollToScreenBottom),\n\n            \"accept\" => Ok(Action::Accept),\n            \"return-selection\" => Ok(Action::ReturnSelection),\n            \"copy\" => Ok(Action::Copy),\n            \"delete\" => Ok(Action::Delete),\n            \"return-original\" => Ok(Action::ReturnOriginal),\n            \"return-query\" => Ok(Action::ReturnQuery),\n            \"exit\" => Ok(Action::Exit),\n            \"redraw\" => Ok(Action::Redraw),\n            \"cycle-filter-mode\" => Ok(Action::CycleFilterMode),\n            \"cycle-search-mode\" => Ok(Action::CycleSearchMode),\n            \"switch-context\" => Ok(Action::SwitchContext),\n            \"clear-context\" => Ok(Action::ClearContext),\n            \"toggle-tab\" => Ok(Action::ToggleTab),\n\n            \"vim-enter-normal\" => Ok(Action::VimEnterNormal),\n            \"vim-enter-insert\" => Ok(Action::VimEnterInsert),\n            \"vim-enter-insert-after\" => Ok(Action::VimEnterInsertAfter),\n            \"vim-enter-insert-at-start\" => Ok(Action::VimEnterInsertAtStart),\n            \"vim-enter-insert-at-end\" => Ok(Action::VimEnterInsertAtEnd),\n            \"vim-search-insert\" => Ok(Action::VimSearchInsert),\n            \"vim-change-to-end\" => Ok(Action::VimChangeToEnd),\n            \"enter-prefix-mode\" => Ok(Action::EnterPrefixMode),\n\n            \"inspect-previous\" => Ok(Action::InspectPrevious),\n            \"inspect-next\" => Ok(Action::InspectNext),\n\n            \"noop\" => Ok(Action::Noop),\n\n            _ => Err(format!(\"unknown action: {s}\")),\n        }\n    }\n\n    /// Convert to a kebab-case string.\n    pub fn as_str(&self) -> String {\n        match self {\n            Action::CursorLeft => \"cursor-left\".to_string(),\n            Action::CursorRight => \"cursor-right\".to_string(),\n            Action::CursorWordLeft => \"cursor-word-left\".to_string(),\n            Action::CursorWordRight => \"cursor-word-right\".to_string(),\n            Action::CursorWordEnd => \"cursor-word-end\".to_string(),\n            Action::CursorStart => \"cursor-start\".to_string(),\n            Action::CursorEnd => \"cursor-end\".to_string(),\n\n            Action::DeleteCharBefore => \"delete-char-before\".to_string(),\n            Action::DeleteCharAfter => \"delete-char-after\".to_string(),\n            Action::DeleteWordBefore => \"delete-word-before\".to_string(),\n            Action::DeleteWordAfter => \"delete-word-after\".to_string(),\n            Action::DeleteToWordBoundary => \"delete-to-word-boundary\".to_string(),\n            Action::ClearLine => \"clear-line\".to_string(),\n            Action::ClearToStart => \"clear-to-start\".to_string(),\n            Action::ClearToEnd => \"clear-to-end\".to_string(),\n\n            Action::SelectNext => \"select-next\".to_string(),\n            Action::SelectPrevious => \"select-previous\".to_string(),\n            Action::ScrollHalfPageUp => \"scroll-half-page-up\".to_string(),\n            Action::ScrollHalfPageDown => \"scroll-half-page-down\".to_string(),\n            Action::ScrollPageUp => \"scroll-page-up\".to_string(),\n            Action::ScrollPageDown => \"scroll-page-down\".to_string(),\n            Action::ScrollToTop => \"scroll-to-top\".to_string(),\n            Action::ScrollToBottom => \"scroll-to-bottom\".to_string(),\n            Action::ScrollToScreenTop => \"scroll-to-screen-top\".to_string(),\n            Action::ScrollToScreenMiddle => \"scroll-to-screen-middle\".to_string(),\n            Action::ScrollToScreenBottom => \"scroll-to-screen-bottom\".to_string(),\n\n            Action::Accept => \"accept\".to_string(),\n            Action::AcceptNth(n) => format!(\"accept-{n}\"),\n            Action::ReturnSelection => \"return-selection\".to_string(),\n            Action::ReturnSelectionNth(n) => format!(\"return-selection-{n}\"),\n            Action::Copy => \"copy\".to_string(),\n            Action::Delete => \"delete\".to_string(),\n            Action::ReturnOriginal => \"return-original\".to_string(),\n            Action::ReturnQuery => \"return-query\".to_string(),\n            Action::Exit => \"exit\".to_string(),\n            Action::Redraw => \"redraw\".to_string(),\n            Action::CycleFilterMode => \"cycle-filter-mode\".to_string(),\n            Action::CycleSearchMode => \"cycle-search-mode\".to_string(),\n            Action::SwitchContext => \"switch-context\".to_string(),\n            Action::ClearContext => \"clear-context\".to_string(),\n            Action::ToggleTab => \"toggle-tab\".to_string(),\n\n            Action::VimEnterNormal => \"vim-enter-normal\".to_string(),\n            Action::VimEnterInsert => \"vim-enter-insert\".to_string(),\n            Action::VimEnterInsertAfter => \"vim-enter-insert-after\".to_string(),\n            Action::VimEnterInsertAtStart => \"vim-enter-insert-at-start\".to_string(),\n            Action::VimEnterInsertAtEnd => \"vim-enter-insert-at-end\".to_string(),\n            Action::VimSearchInsert => \"vim-search-insert\".to_string(),\n            Action::VimChangeToEnd => \"vim-change-to-end\".to_string(),\n            Action::EnterPrefixMode => \"enter-prefix-mode\".to_string(),\n\n            Action::InspectPrevious => \"inspect-previous\".to_string(),\n            Action::InspectNext => \"inspect-next\".to_string(),\n\n            Action::Noop => \"noop\".to_string(),\n        }\n    }\n}\n\nimpl fmt::Display for Action {\n    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {\n        write!(f, \"{}\", self.as_str())\n    }\n}\n\nimpl Serialize for Action {\n    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {\n        serializer.serialize_str(&self.as_str())\n    }\n}\n\nimpl<'de> Deserialize<'de> for Action {\n    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {\n        let s = String::deserialize(deserializer)?;\n        Action::from_str(&s).map_err(serde::de::Error::custom)\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn parse_basic_actions() {\n        assert_eq!(Action::from_str(\"cursor-left\").unwrap(), Action::CursorLeft);\n        assert_eq!(Action::from_str(\"accept\").unwrap(), Action::Accept);\n        assert_eq!(Action::from_str(\"exit\").unwrap(), Action::Exit);\n        assert_eq!(Action::from_str(\"noop\").unwrap(), Action::Noop);\n        assert_eq!(\n            Action::from_str(\"vim-enter-normal\").unwrap(),\n            Action::VimEnterNormal\n        );\n    }\n\n    #[test]\n    fn parse_accept_nth() {\n        assert_eq!(Action::from_str(\"accept-1\").unwrap(), Action::AcceptNth(1));\n        assert_eq!(Action::from_str(\"accept-9\").unwrap(), Action::AcceptNth(9));\n    }\n\n    #[test]\n    fn parse_return_selection() {\n        assert_eq!(\n            Action::from_str(\"return-selection\").unwrap(),\n            Action::ReturnSelection\n        );\n        assert_eq!(\n            Action::from_str(\"return-selection-1\").unwrap(),\n            Action::ReturnSelectionNth(1)\n        );\n        assert_eq!(\n            Action::from_str(\"return-selection-9\").unwrap(),\n            Action::ReturnSelectionNth(9)\n        );\n    }\n\n    #[test]\n    fn parse_unknown_action() {\n        assert!(Action::from_str(\"unknown-action\").is_err());\n        assert!(Action::from_str(\"accept-0\").is_err());\n        assert!(Action::from_str(\"accept-10\").is_err());\n        assert!(Action::from_str(\"return-selection-0\").is_err());\n        assert!(Action::from_str(\"return-selection-10\").is_err());\n    }\n\n    #[test]\n    fn round_trip() {\n        let actions = vec![\n            Action::CursorLeft,\n            Action::Accept,\n            Action::AcceptNth(5),\n            Action::ReturnSelection,\n            Action::ReturnSelectionNth(3),\n            Action::VimSearchInsert,\n            Action::ScrollToScreenMiddle,\n        ];\n        for action in actions {\n            let s = action.as_str();\n            let parsed = Action::from_str(&s).unwrap();\n            assert_eq!(action, parsed);\n        }\n    }\n\n    #[test]\n    fn serde_round_trip() {\n        let action = Action::CursorLeft;\n        let json = serde_json::to_string(&action).unwrap();\n        assert_eq!(json, \"\\\"cursor-left\\\"\");\n        let parsed: Action = serde_json::from_str(&json).unwrap();\n        assert_eq!(parsed, Action::CursorLeft);\n\n        let action = Action::AcceptNth(3);\n        let json = serde_json::to_string(&action).unwrap();\n        assert_eq!(json, \"\\\"accept-3\\\"\");\n        let parsed: Action = serde_json::from_str(&json).unwrap();\n        assert_eq!(parsed, Action::AcceptNth(3));\n    }\n}\n"
  },
  {
    "path": "crates/atuin/src/command/client/search/keybindings/conditions.rs",
    "content": "use std::fmt;\n\nuse serde::{Deserialize, Deserializer, Serialize, Serializer};\n\n/// Atomic (leaf) conditions that can be evaluated against state.\n#[derive(Debug, Clone, PartialEq, Eq)]\npub enum ConditionAtom {\n    CursorAtStart,\n    CursorAtEnd,\n    InputEmpty,\n    OriginalInputEmpty,\n    ListAtEnd,\n    ListAtStart,\n    NoResults,\n    HasResults,\n    HasContext,\n}\n\n/// Boolean expression tree over condition atoms.\n///\n/// Supports negation, conjunction, and disjunction with standard precedence:\n/// `!` binds tightest, then `&&`, then `||`.\n///\n/// Examples of valid expression strings:\n/// - `\"cursor-at-start\"` (bare atom)\n/// - `\"!no-results\"` (negation)\n/// - `\"cursor-at-start && input-empty\"` (conjunction)\n/// - `\"list-at-start || no-results\"` (disjunction)\n/// - `\"(cursor-at-start && !input-empty) || no-results\"` (grouping)\n#[derive(Debug, Clone, PartialEq, Eq)]\npub enum ConditionExpr {\n    Atom(ConditionAtom),\n    Not(Box<ConditionExpr>),\n    And(Box<ConditionExpr>, Box<ConditionExpr>),\n    Or(Box<ConditionExpr>, Box<ConditionExpr>),\n}\n\n/// Context needed to evaluate conditions. This is a pure snapshot of state —\n/// no references to mutable data.\npub struct EvalContext {\n    /// Current cursor position (unicode width units).\n    pub cursor_position: usize,\n    /// Width of the input string in unicode width units.\n    pub input_width: usize,\n    /// Byte length of the input string.\n    pub input_byte_len: usize,\n    /// Currently selected index in the results list.\n    pub selected_index: usize,\n    /// Total number of results.\n    pub results_len: usize,\n    /// Whether the original input (query passed to the TUI) was empty.\n    pub original_input_empty: bool,\n    /// Whether we use a search context of a command from the history.\n    pub has_context: bool,\n}\n\n// ---------------------------------------------------------------------------\n// ConditionAtom\n// ---------------------------------------------------------------------------\n\nimpl ConditionAtom {\n    /// Evaluate this atom against the given context.\n    pub fn evaluate(&self, ctx: &EvalContext) -> bool {\n        match self {\n            ConditionAtom::CursorAtStart => ctx.cursor_position == 0,\n            ConditionAtom::CursorAtEnd => ctx.cursor_position == ctx.input_width,\n            ConditionAtom::InputEmpty => ctx.input_byte_len == 0,\n            ConditionAtom::OriginalInputEmpty => ctx.original_input_empty,\n            ConditionAtom::ListAtEnd => {\n                ctx.results_len == 0 || ctx.selected_index >= ctx.results_len.saturating_sub(1)\n            }\n            ConditionAtom::ListAtStart => ctx.results_len == 0 || ctx.selected_index == 0,\n            ConditionAtom::NoResults => ctx.results_len == 0,\n            ConditionAtom::HasResults => ctx.results_len > 0,\n            ConditionAtom::HasContext => ctx.has_context,\n        }\n    }\n\n    /// Parse from a kebab-case string.\n    pub fn from_str(s: &str) -> Result<Self, String> {\n        match s {\n            \"cursor-at-start\" => Ok(ConditionAtom::CursorAtStart),\n            \"cursor-at-end\" => Ok(ConditionAtom::CursorAtEnd),\n            \"input-empty\" => Ok(ConditionAtom::InputEmpty),\n            \"original-input-empty\" => Ok(ConditionAtom::OriginalInputEmpty),\n            \"list-at-end\" => Ok(ConditionAtom::ListAtEnd),\n            \"list-at-start\" => Ok(ConditionAtom::ListAtStart),\n            \"no-results\" => Ok(ConditionAtom::NoResults),\n            \"has-results\" => Ok(ConditionAtom::HasResults),\n            \"has-context\" => Ok(ConditionAtom::HasContext),\n            _ => Err(format!(\"unknown condition: {s}\")),\n        }\n    }\n\n    /// Convert to a kebab-case string.\n    pub fn as_str(&self) -> &'static str {\n        match self {\n            ConditionAtom::CursorAtStart => \"cursor-at-start\",\n            ConditionAtom::CursorAtEnd => \"cursor-at-end\",\n            ConditionAtom::InputEmpty => \"input-empty\",\n            ConditionAtom::OriginalInputEmpty => \"original-input-empty\",\n            ConditionAtom::ListAtEnd => \"list-at-end\",\n            ConditionAtom::ListAtStart => \"list-at-start\",\n            ConditionAtom::NoResults => \"no-results\",\n            ConditionAtom::HasResults => \"has-results\",\n            ConditionAtom::HasContext => \"has-context\",\n        }\n    }\n}\n\nimpl fmt::Display for ConditionAtom {\n    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {\n        write!(f, \"{}\", self.as_str())\n    }\n}\n\n// ---------------------------------------------------------------------------\n// ConditionExpr — evaluation\n// ---------------------------------------------------------------------------\n\nimpl ConditionExpr {\n    /// Evaluate this expression against the given context.\n    pub fn evaluate(&self, ctx: &EvalContext) -> bool {\n        match self {\n            ConditionExpr::Atom(atom) => atom.evaluate(ctx),\n            ConditionExpr::Not(inner) => !inner.evaluate(ctx),\n            ConditionExpr::And(lhs, rhs) => lhs.evaluate(ctx) && rhs.evaluate(ctx),\n            ConditionExpr::Or(lhs, rhs) => lhs.evaluate(ctx) || rhs.evaluate(ctx),\n        }\n    }\n}\n\n// ---------------------------------------------------------------------------\n// ConditionExpr — ergonomic builders\n// ---------------------------------------------------------------------------\n\nimpl From<ConditionAtom> for ConditionExpr {\n    fn from(atom: ConditionAtom) -> Self {\n        ConditionExpr::Atom(atom)\n    }\n}\n\n#[allow(dead_code)]\nimpl ConditionExpr {\n    /// Negate this expression: `!self`.\n    pub fn not(self) -> Self {\n        ConditionExpr::Not(Box::new(self))\n    }\n\n    /// Conjoin with another expression: `self && other`.\n    pub fn and(self, other: ConditionExpr) -> Self {\n        ConditionExpr::And(Box::new(self), Box::new(other))\n    }\n\n    /// Disjoin with another expression: `self || other`.\n    pub fn or(self, other: ConditionExpr) -> Self {\n        ConditionExpr::Or(Box::new(self), Box::new(other))\n    }\n}\n\n// ---------------------------------------------------------------------------\n// ConditionExpr — parser\n// ---------------------------------------------------------------------------\n\n/// Recursive descent parser for boolean condition expressions.\n///\n/// Grammar (standard boolean precedence):\n/// ```text\n/// expr     = or_expr\n/// or_expr  = and_expr (\"||\" and_expr)*\n/// and_expr = unary (\"&&\" unary)*\n/// unary    = \"!\" unary | primary\n/// primary  = atom | \"(\" expr \")\"\n/// atom     = [a-z][a-z0-9-]*\n/// ```\nstruct ExprParser<'a> {\n    input: &'a str,\n    pos: usize,\n}\n\nimpl<'a> ExprParser<'a> {\n    fn new(input: &'a str) -> Self {\n        Self { input, pos: 0 }\n    }\n\n    fn skip_whitespace(&mut self) {\n        while self.pos < self.input.len() && self.input.as_bytes()[self.pos].is_ascii_whitespace() {\n            self.pos += 1;\n        }\n    }\n\n    fn starts_with(&mut self, s: &str) -> bool {\n        self.skip_whitespace();\n        self.input[self.pos..].starts_with(s)\n    }\n\n    fn consume(&mut self, s: &str) -> bool {\n        self.skip_whitespace();\n        if self.input[self.pos..].starts_with(s) {\n            self.pos += s.len();\n            true\n        } else {\n            false\n        }\n    }\n\n    /// Parse a full expression, expecting to consume all input.\n    fn parse(mut self) -> Result<ConditionExpr, String> {\n        let expr = self.parse_or()?;\n        self.skip_whitespace();\n        if self.pos < self.input.len() {\n            return Err(format!(\n                \"unexpected input at position {}: {:?}\",\n                self.pos,\n                &self.input[self.pos..]\n            ));\n        }\n        Ok(expr)\n    }\n\n    /// `or_expr` = `and_expr` (\"||\" `and_expr`)*\n    fn parse_or(&mut self) -> Result<ConditionExpr, String> {\n        let mut left = self.parse_and()?;\n        while self.starts_with(\"||\") {\n            self.consume(\"||\");\n            let right = self.parse_and()?;\n            left = ConditionExpr::Or(Box::new(left), Box::new(right));\n        }\n        Ok(left)\n    }\n\n    /// `and_expr` = unary (\"&&\" unary)*\n    fn parse_and(&mut self) -> Result<ConditionExpr, String> {\n        let mut left = self.parse_unary()?;\n        while self.starts_with(\"&&\") {\n            self.consume(\"&&\");\n            let right = self.parse_unary()?;\n            left = ConditionExpr::And(Box::new(left), Box::new(right));\n        }\n        Ok(left)\n    }\n\n    /// unary = \"!\" unary | primary\n    fn parse_unary(&mut self) -> Result<ConditionExpr, String> {\n        if self.consume(\"!\") {\n            let inner = self.parse_unary()?;\n            Ok(ConditionExpr::Not(Box::new(inner)))\n        } else {\n            self.parse_primary()\n        }\n    }\n\n    /// primary = \"(\" expr \")\" | atom\n    fn parse_primary(&mut self) -> Result<ConditionExpr, String> {\n        if self.consume(\"(\") {\n            let expr = self.parse_or()?;\n            if !self.consume(\")\") {\n                return Err(format!(\"expected ')' at position {}\", self.pos));\n            }\n            Ok(expr)\n        } else {\n            self.parse_atom()\n        }\n    }\n\n    /// atom = [a-z][a-z0-9-]*\n    fn parse_atom(&mut self) -> Result<ConditionExpr, String> {\n        self.skip_whitespace();\n        let start = self.pos;\n        while self.pos < self.input.len() {\n            let b = self.input.as_bytes()[self.pos];\n            if b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-' {\n                self.pos += 1;\n            } else {\n                break;\n            }\n        }\n        if self.pos == start {\n            return Err(format!(\"expected condition name at position {}\", self.pos));\n        }\n        let name = &self.input[start..self.pos];\n        let atom = ConditionAtom::from_str(name)?;\n        Ok(ConditionExpr::Atom(atom))\n    }\n}\n\nimpl ConditionExpr {\n    /// Parse a condition expression from a string.\n    pub fn parse(s: &str) -> Result<Self, String> {\n        let parser = ExprParser::new(s);\n        parser.parse()\n    }\n}\n\n// ---------------------------------------------------------------------------\n// ConditionExpr — Display\n// ---------------------------------------------------------------------------\n\n/// Precedence levels for minimal-parentheses display.\n#[derive(PartialEq, Eq, PartialOrd, Ord, Clone, Copy)]\nenum Prec {\n    Or = 0,\n    And = 1,\n    Not = 2,\n    Atom = 3,\n}\n\nimpl ConditionExpr {\n    fn prec(&self) -> Prec {\n        match self {\n            ConditionExpr::Or(..) => Prec::Or,\n            ConditionExpr::And(..) => Prec::And,\n            ConditionExpr::Not(..) => Prec::Not,\n            ConditionExpr::Atom(..) => Prec::Atom,\n        }\n    }\n\n    fn fmt_with_prec(&self, f: &mut fmt::Formatter<'_>, parent_prec: Prec) -> fmt::Result {\n        let needs_parens = self.prec() < parent_prec;\n        if needs_parens {\n            write!(f, \"(\")?;\n        }\n        match self {\n            ConditionExpr::Atom(atom) => write!(f, \"{atom}\")?,\n            ConditionExpr::Not(inner) => {\n                write!(f, \"!\")?;\n                inner.fmt_with_prec(f, Prec::Not)?;\n            }\n            ConditionExpr::And(lhs, rhs) => {\n                lhs.fmt_with_prec(f, Prec::And)?;\n                write!(f, \" && \")?;\n                rhs.fmt_with_prec(f, Prec::And)?;\n            }\n            ConditionExpr::Or(lhs, rhs) => {\n                lhs.fmt_with_prec(f, Prec::Or)?;\n                write!(f, \" || \")?;\n                rhs.fmt_with_prec(f, Prec::Or)?;\n            }\n        }\n        if needs_parens {\n            write!(f, \")\")?;\n        }\n        Ok(())\n    }\n}\n\nimpl fmt::Display for ConditionExpr {\n    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {\n        self.fmt_with_prec(f, Prec::Or)\n    }\n}\n\n// ---------------------------------------------------------------------------\n// Serde\n// ---------------------------------------------------------------------------\n\nimpl Serialize for ConditionExpr {\n    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {\n        serializer.serialize_str(&self.to_string())\n    }\n}\n\nimpl<'de> Deserialize<'de> for ConditionExpr {\n    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {\n        let s = String::deserialize(deserializer)?;\n        ConditionExpr::parse(&s).map_err(serde::de::Error::custom)\n    }\n}\n\n// ---------------------------------------------------------------------------\n// Tests\n// ---------------------------------------------------------------------------\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    fn ctx(\n        cursor: usize,\n        width: usize,\n        byte_len: usize,\n        selected: usize,\n        len: usize,\n    ) -> EvalContext {\n        ctx_with_original(cursor, width, byte_len, selected, len, false)\n    }\n\n    fn ctx_with_original(\n        cursor: usize,\n        width: usize,\n        byte_len: usize,\n        selected: usize,\n        len: usize,\n        original_input_empty: bool,\n    ) -> EvalContext {\n        EvalContext {\n            cursor_position: cursor,\n            input_width: width,\n            input_byte_len: byte_len,\n            selected_index: selected,\n            results_len: len,\n            original_input_empty,\n            has_context: false,\n        }\n    }\n\n    // -- Atom evaluation (carried over from Phase 0) --\n\n    #[test]\n    fn atom_cursor_at_start() {\n        assert!(ConditionAtom::CursorAtStart.evaluate(&ctx(0, 5, 5, 0, 10)));\n        assert!(!ConditionAtom::CursorAtStart.evaluate(&ctx(3, 5, 5, 0, 10)));\n    }\n\n    #[test]\n    fn atom_cursor_at_end() {\n        assert!(ConditionAtom::CursorAtEnd.evaluate(&ctx(5, 5, 5, 0, 10)));\n        assert!(!ConditionAtom::CursorAtEnd.evaluate(&ctx(3, 5, 5, 0, 10)));\n        assert!(ConditionAtom::CursorAtEnd.evaluate(&ctx(0, 0, 0, 0, 10)));\n    }\n\n    #[test]\n    fn atom_input_empty() {\n        assert!(ConditionAtom::InputEmpty.evaluate(&ctx(0, 0, 0, 0, 10)));\n        assert!(!ConditionAtom::InputEmpty.evaluate(&ctx(0, 5, 5, 0, 10)));\n    }\n\n    #[test]\n    fn atom_original_input_empty() {\n        // original_input_empty = true\n        assert!(\n            ConditionAtom::OriginalInputEmpty.evaluate(&ctx_with_original(0, 0, 0, 0, 10, true))\n        );\n        // original_input_empty = false\n        assert!(\n            !ConditionAtom::OriginalInputEmpty.evaluate(&ctx_with_original(0, 0, 0, 0, 10, false))\n        );\n        // original_input_empty is independent of current input state\n        assert!(\n            ConditionAtom::OriginalInputEmpty.evaluate(&ctx_with_original(0, 5, 5, 0, 10, true))\n        );\n    }\n\n    #[test]\n    fn atom_list_at_end() {\n        assert!(ConditionAtom::ListAtEnd.evaluate(&ctx(0, 0, 0, 99, 100)));\n        assert!(!ConditionAtom::ListAtEnd.evaluate(&ctx(0, 0, 0, 50, 100)));\n        assert!(ConditionAtom::ListAtEnd.evaluate(&ctx(0, 0, 0, 0, 0)));\n    }\n\n    #[test]\n    fn atom_list_at_start() {\n        assert!(ConditionAtom::ListAtStart.evaluate(&ctx(0, 0, 0, 0, 100)));\n        assert!(!ConditionAtom::ListAtStart.evaluate(&ctx(0, 0, 0, 50, 100)));\n        assert!(ConditionAtom::ListAtStart.evaluate(&ctx(0, 0, 0, 0, 0)));\n    }\n\n    #[test]\n    fn atom_no_results_and_has_results() {\n        assert!(ConditionAtom::NoResults.evaluate(&ctx(0, 0, 0, 0, 0)));\n        assert!(!ConditionAtom::NoResults.evaluate(&ctx(0, 0, 0, 0, 5)));\n        assert!(ConditionAtom::HasResults.evaluate(&ctx(0, 0, 0, 0, 5)));\n        assert!(!ConditionAtom::HasResults.evaluate(&ctx(0, 0, 0, 0, 0)));\n    }\n\n    #[test]\n    fn atom_has_context() {\n        let mut context = ctx(0, 0, 0, 0, 0);\n        assert!(!ConditionAtom::HasContext.evaluate(&context));\n        context.has_context = true;\n        assert!(ConditionAtom::HasContext.evaluate(&context));\n    }\n\n    #[test]\n    fn atom_parse_round_trip() {\n        let conditions = [\n            \"cursor-at-start\",\n            \"cursor-at-end\",\n            \"input-empty\",\n            \"original-input-empty\",\n            \"list-at-end\",\n            \"list-at-start\",\n            \"no-results\",\n            \"has-results\",\n        ];\n        for s in conditions {\n            let c = ConditionAtom::from_str(s).unwrap();\n            assert_eq!(c.as_str(), s);\n        }\n    }\n\n    #[test]\n    fn atom_parse_unknown() {\n        assert!(ConditionAtom::from_str(\"unknown-condition\").is_err());\n    }\n\n    // -- Parser tests --\n\n    #[test]\n    fn parse_bare_atom() {\n        let expr = ConditionExpr::parse(\"cursor-at-start\").unwrap();\n        assert_eq!(expr, ConditionExpr::Atom(ConditionAtom::CursorAtStart));\n    }\n\n    #[test]\n    fn parse_negation() {\n        let expr = ConditionExpr::parse(\"!no-results\").unwrap();\n        assert_eq!(\n            expr,\n            ConditionExpr::Not(Box::new(ConditionExpr::Atom(ConditionAtom::NoResults)))\n        );\n    }\n\n    #[test]\n    fn parse_double_negation() {\n        let expr = ConditionExpr::parse(\"!!no-results\").unwrap();\n        assert_eq!(\n            expr,\n            ConditionExpr::Not(Box::new(ConditionExpr::Not(Box::new(ConditionExpr::Atom(\n                ConditionAtom::NoResults\n            )))))\n        );\n    }\n\n    #[test]\n    fn parse_and() {\n        let expr = ConditionExpr::parse(\"cursor-at-start && input-empty\").unwrap();\n        assert_eq!(\n            expr,\n            ConditionExpr::And(\n                Box::new(ConditionExpr::Atom(ConditionAtom::CursorAtStart)),\n                Box::new(ConditionExpr::Atom(ConditionAtom::InputEmpty)),\n            )\n        );\n    }\n\n    #[test]\n    fn parse_or() {\n        let expr = ConditionExpr::parse(\"list-at-start || no-results\").unwrap();\n        assert_eq!(\n            expr,\n            ConditionExpr::Or(\n                Box::new(ConditionExpr::Atom(ConditionAtom::ListAtStart)),\n                Box::new(ConditionExpr::Atom(ConditionAtom::NoResults)),\n            )\n        );\n    }\n\n    #[test]\n    fn parse_precedence_and_binds_tighter_than_or() {\n        // \"a || b && c\" should parse as \"a || (b && c)\"\n        let expr = ConditionExpr::parse(\"cursor-at-start || input-empty && no-results\").unwrap();\n        assert_eq!(\n            expr,\n            ConditionExpr::Or(\n                Box::new(ConditionExpr::Atom(ConditionAtom::CursorAtStart)),\n                Box::new(ConditionExpr::And(\n                    Box::new(ConditionExpr::Atom(ConditionAtom::InputEmpty)),\n                    Box::new(ConditionExpr::Atom(ConditionAtom::NoResults)),\n                )),\n            )\n        );\n    }\n\n    #[test]\n    fn parse_parens_override_precedence() {\n        // \"(a || b) && c\"\n        let expr = ConditionExpr::parse(\"(cursor-at-start || input-empty) && no-results\").unwrap();\n        assert_eq!(\n            expr,\n            ConditionExpr::And(\n                Box::new(ConditionExpr::Or(\n                    Box::new(ConditionExpr::Atom(ConditionAtom::CursorAtStart)),\n                    Box::new(ConditionExpr::Atom(ConditionAtom::InputEmpty)),\n                )),\n                Box::new(ConditionExpr::Atom(ConditionAtom::NoResults)),\n            )\n        );\n    }\n\n    #[test]\n    fn parse_complex_nested() {\n        // \"(a && !b) || c\"\n        let expr = ConditionExpr::parse(\"(cursor-at-start && !input-empty) || no-results\").unwrap();\n        assert_eq!(\n            expr,\n            ConditionExpr::Or(\n                Box::new(ConditionExpr::And(\n                    Box::new(ConditionExpr::Atom(ConditionAtom::CursorAtStart)),\n                    Box::new(ConditionExpr::Not(Box::new(ConditionExpr::Atom(\n                        ConditionAtom::InputEmpty\n                    )))),\n                )),\n                Box::new(ConditionExpr::Atom(ConditionAtom::NoResults)),\n            )\n        );\n    }\n\n    #[test]\n    fn parse_whitespace_tolerance() {\n        let a = ConditionExpr::parse(\"cursor-at-start||input-empty\").unwrap();\n        let b = ConditionExpr::parse(\"cursor-at-start || input-empty\").unwrap();\n        let c = ConditionExpr::parse(\"  cursor-at-start  ||  input-empty  \").unwrap();\n        assert_eq!(a, b);\n        assert_eq!(b, c);\n    }\n\n    #[test]\n    fn parse_error_unknown_atom() {\n        assert!(ConditionExpr::parse(\"unknown-thing\").is_err());\n    }\n\n    #[test]\n    fn parse_error_trailing_input() {\n        assert!(ConditionExpr::parse(\"cursor-at-start blah\").is_err());\n    }\n\n    #[test]\n    fn parse_error_unmatched_paren() {\n        assert!(ConditionExpr::parse(\"(cursor-at-start\").is_err());\n    }\n\n    #[test]\n    fn parse_error_empty() {\n        assert!(ConditionExpr::parse(\"\").is_err());\n    }\n\n    // -- Expression evaluation --\n\n    #[test]\n    fn eval_not() {\n        let expr = ConditionExpr::parse(\"!no-results\").unwrap();\n        // Has results → !no-results is true\n        assert!(expr.evaluate(&ctx(0, 0, 0, 0, 5)));\n        // No results → !no-results is false\n        assert!(!expr.evaluate(&ctx(0, 0, 0, 0, 0)));\n    }\n\n    #[test]\n    fn eval_and() {\n        let expr = ConditionExpr::parse(\"cursor-at-start && input-empty\").unwrap();\n        // Both true\n        assert!(expr.evaluate(&ctx(0, 0, 0, 0, 10)));\n        // First true, second false (non-empty input)\n        assert!(!expr.evaluate(&ctx(0, 5, 5, 0, 10)));\n        // First false (cursor not at start)\n        assert!(!expr.evaluate(&ctx(3, 5, 5, 0, 10)));\n    }\n\n    #[test]\n    fn eval_or() {\n        let expr = ConditionExpr::parse(\"list-at-start || no-results\").unwrap();\n        // list at bottom (selected=0)\n        assert!(expr.evaluate(&ctx(0, 0, 0, 0, 10)));\n        // no results\n        assert!(expr.evaluate(&ctx(0, 0, 0, 0, 0)));\n        // neither\n        assert!(!expr.evaluate(&ctx(0, 0, 0, 5, 10)));\n    }\n\n    #[test]\n    fn eval_complex_nested() {\n        // (cursor-at-start && !input-empty) || no-results\n        let expr = ConditionExpr::parse(\"(cursor-at-start && !input-empty) || no-results\").unwrap();\n\n        // cursor at start, input not empty → true (left branch)\n        assert!(expr.evaluate(&ctx(0, 5, 5, 0, 10)));\n        // no results → true (right branch)\n        assert!(expr.evaluate(&ctx(3, 5, 5, 0, 0)));\n        // cursor not at start, has results → false\n        assert!(!expr.evaluate(&ctx(3, 5, 5, 0, 10)));\n        // cursor at start, input empty → false (left: && fails; right: has results)\n        assert!(!expr.evaluate(&ctx(0, 0, 0, 0, 10)));\n    }\n\n    // -- Display --\n\n    #[test]\n    fn display_atom() {\n        let expr = ConditionExpr::Atom(ConditionAtom::CursorAtStart);\n        assert_eq!(expr.to_string(), \"cursor-at-start\");\n    }\n\n    #[test]\n    fn display_not() {\n        let expr = ConditionExpr::Atom(ConditionAtom::NoResults).not();\n        assert_eq!(expr.to_string(), \"!no-results\");\n    }\n\n    #[test]\n    fn display_and() {\n        let expr = ConditionExpr::Atom(ConditionAtom::CursorAtStart)\n            .and(ConditionExpr::Atom(ConditionAtom::InputEmpty));\n        assert_eq!(expr.to_string(), \"cursor-at-start && input-empty\");\n    }\n\n    #[test]\n    fn display_or() {\n        let expr = ConditionExpr::Atom(ConditionAtom::ListAtStart)\n            .or(ConditionExpr::Atom(ConditionAtom::NoResults));\n        assert_eq!(expr.to_string(), \"list-at-start || no-results\");\n    }\n\n    #[test]\n    fn display_parens_when_needed() {\n        // (a || b) && c — the Or inside And needs parens\n        let expr = ConditionExpr::Atom(ConditionAtom::CursorAtStart)\n            .or(ConditionExpr::Atom(ConditionAtom::InputEmpty))\n            .and(ConditionExpr::Atom(ConditionAtom::NoResults));\n        assert_eq!(\n            expr.to_string(),\n            \"(cursor-at-start || input-empty) && no-results\"\n        );\n    }\n\n    #[test]\n    fn display_no_parens_when_not_needed() {\n        // a || b && c — no parens needed (and binds tighter)\n        let inner_and = ConditionExpr::Atom(ConditionAtom::InputEmpty)\n            .and(ConditionExpr::Atom(ConditionAtom::NoResults));\n        let expr = ConditionExpr::Atom(ConditionAtom::CursorAtStart).or(inner_and);\n        assert_eq!(\n            expr.to_string(),\n            \"cursor-at-start || input-empty && no-results\"\n        );\n    }\n\n    // -- Display round-trip --\n\n    #[test]\n    fn display_round_trip() {\n        let cases = [\n            \"cursor-at-start\",\n            \"!no-results\",\n            \"cursor-at-start && input-empty\",\n            \"list-at-start || no-results\",\n            \"(cursor-at-start || input-empty) && no-results\",\n            \"(cursor-at-start && !input-empty) || no-results\",\n        ];\n        for s in cases {\n            let expr = ConditionExpr::parse(s).unwrap();\n            let displayed = expr.to_string();\n            let reparsed = ConditionExpr::parse(&displayed).unwrap();\n            assert_eq!(expr, reparsed, \"round-trip failed for: {s}\");\n        }\n    }\n\n    // -- Serde --\n\n    #[test]\n    fn serde_simple_atom() {\n        let expr = ConditionExpr::Atom(ConditionAtom::CursorAtStart);\n        let json = serde_json::to_string(&expr).unwrap();\n        assert_eq!(json, \"\\\"cursor-at-start\\\"\");\n        let parsed: ConditionExpr = serde_json::from_str(&json).unwrap();\n        assert_eq!(parsed, expr);\n    }\n\n    #[test]\n    fn serde_compound_expression() {\n        let json = \"\\\"cursor-at-start && !input-empty\\\"\";\n        let parsed: ConditionExpr = serde_json::from_str(json).unwrap();\n        let expected = ConditionExpr::And(\n            Box::new(ConditionExpr::Atom(ConditionAtom::CursorAtStart)),\n            Box::new(ConditionExpr::Not(Box::new(ConditionExpr::Atom(\n                ConditionAtom::InputEmpty,\n            )))),\n        );\n        assert_eq!(parsed, expected);\n    }\n\n    #[test]\n    fn serde_round_trip() {\n        let expr = ConditionExpr::parse(\"(cursor-at-start && !input-empty) || no-results\").unwrap();\n        let json = serde_json::to_string(&expr).unwrap();\n        let parsed: ConditionExpr = serde_json::from_str(&json).unwrap();\n        assert_eq!(expr, parsed);\n    }\n\n    // -- From<ConditionAtom> --\n\n    #[test]\n    fn from_atom_into_expr() {\n        let expr: ConditionExpr = ConditionAtom::CursorAtStart.into();\n        assert_eq!(expr, ConditionExpr::Atom(ConditionAtom::CursorAtStart));\n    }\n\n    // -- Builder helpers --\n\n    #[test]\n    fn builder_chain() {\n        let expr = ConditionExpr::from(ConditionAtom::CursorAtStart)\n            .and(ConditionExpr::from(ConditionAtom::InputEmpty).not())\n            .or(ConditionExpr::from(ConditionAtom::NoResults));\n        // And binds tighter than Or, so no parens needed around the And\n        assert_eq!(\n            expr.to_string(),\n            \"cursor-at-start && !input-empty || no-results\"\n        );\n    }\n}\n"
  },
  {
    "path": "crates/atuin/src/command/client/search/keybindings/defaults.rs",
    "content": "use std::collections::HashMap;\n\nuse atuin_client::settings::{KeyBindingConfig, Settings};\nuse tracing::warn;\n\nuse super::actions::Action;\nuse super::conditions::{ConditionAtom, ConditionExpr};\nuse super::key::KeyInput;\nuse super::keymap::{KeyBinding, KeyRule, Keymap};\n\n/// Helper to bind a scroll key with optional exit behavior.\n///\n/// When `scroll_exits` is true AND the key scrolls toward index 0 (the newest\n/// entry), we add a conditional rule: at `ListAtStart` → `Exit`, otherwise →\n/// the scroll action.\n///\n/// Whether a key scrolls toward index 0 depends on the `invert` setting:\n/// - Non-inverted: \"down\" / \"j\" move toward index 0, \"up\" / \"k\" move away\n/// - Inverted: \"up\" / \"k\" move toward index 0, \"down\" / \"j\" move away\n///\n/// If `toward_index_zero` is false, or `scroll_exits` is false, we just bind\n/// the key to the plain scroll action (no exit).\nfn bind_scroll_key(\n    km: &mut Keymap,\n    key_str: &str,\n    action: Action,\n    toward_index_zero: bool,\n    scroll_exits: bool,\n) {\n    let k = key(key_str);\n    if scroll_exits && toward_index_zero {\n        km.bind_conditional(\n            k,\n            vec![\n                KeyRule::when(ConditionAtom::ListAtStart, Action::Exit),\n                KeyRule::always(action),\n            ],\n        );\n    } else {\n        km.bind(k, action);\n    }\n}\n\n/// Helper to parse a key string, panicking on invalid keys (these are all\n/// compile-time-known strings).\nfn key(s: &str) -> KeyInput {\n    KeyInput::parse(s).unwrap_or_else(|e| panic!(\"invalid default key {s:?}: {e}\"))\n}\n\n/// All five keymaps bundled together.\n#[derive(Debug, Clone)]\npub struct KeymapSet {\n    pub emacs: Keymap,\n    pub vim_normal: Keymap,\n    pub vim_insert: Keymap,\n    pub inspector: Keymap,\n    pub prefix: Keymap,\n}\n\n// ---------------------------------------------------------------------------\n// Common bindings shared across search-tab keymaps\n// ---------------------------------------------------------------------------\n\n/// Add the bindings that are common to all search-tab keymaps:\n/// ctrl-c, ctrl-g, ctrl-o, and tab.\n///\n/// Note: `esc`/`ctrl-[` are NOT included here because their behavior differs\n/// between emacs (exit), vim-normal (exit), and vim-insert (enter normal mode).\nfn add_common_bindings(km: &mut Keymap) {\n    km.bind(key(\"ctrl-c\"), Action::ReturnOriginal);\n    km.bind(key(\"ctrl-g\"), Action::ReturnOriginal);\n    km.bind(key(\"ctrl-o\"), Action::ToggleTab);\n\n    // Tab: always returns selection without executing (unlike Enter which respects enter_accept)\n    km.bind(key(\"tab\"), Action::ReturnSelection);\n}\n\n/// Returns `Accept` or `ReturnSelection` based on the `enter_accept` setting.\nfn accept_action(settings: &Settings) -> Action {\n    if settings.enter_accept {\n        Action::Accept\n    } else {\n        Action::ReturnSelection\n    }\n}\n\n// ---------------------------------------------------------------------------\n// Emacs keymap (also base for vim-insert)\n// ---------------------------------------------------------------------------\n\n/// Build the default emacs keymap. This encodes the behavior from\n/// `handle_key_input` common section + `handle_search_input` shared section.\n///\n/// The `settings` parameter is used for:\n/// - `keys.prefix` — which ctrl-key enters prefix mode\n/// - `keys.scroll_exits`, `invert` — scroll-at-boundary exit behavior\n/// - `keys.accept_past_line_end` — right arrow at end of line accepts\n/// - `keys.exit_past_line_start` — left arrow at start of line exits\n/// - `keys.accept_past_line_start` — left arrow at start accepts (overrides exit)\n/// - `keys.accept_with_backspace` — backspace at start of line accepts\n/// - `ctrl_n_shortcuts` — whether alt or ctrl is used for numeric shortcuts\n// Keymap builder that enumerates every default binding; not worth splitting.\n#[allow(clippy::too_many_lines)]\npub fn default_emacs_keymap(settings: &Settings) -> Keymap {\n    let mut km = Keymap::new();\n    add_common_bindings(&mut km);\n\n    let accept = accept_action(settings);\n\n    // esc / ctrl-[ → exit\n    km.bind(key(\"esc\"), Action::Exit);\n    km.bind(key(\"ctrl-[\"), Action::Exit);\n\n    // Prefix key: ctrl-<prefix_char> → enter prefix mode\n    let prefix_char = settings.keys.prefix.chars().next().unwrap_or('a');\n    km.bind(key(&format!(\"ctrl-{prefix_char}\")), Action::EnterPrefixMode);\n\n    // --- Accept / navigation edge behaviors (from [keys] settings) ---\n\n    // right: behavior at end of line\n    if settings.keys.accept_past_line_end {\n        km.bind_conditional(\n            key(\"right\"),\n            vec![\n                KeyRule::when(ConditionAtom::CursorAtEnd, Action::ReturnSelection),\n                KeyRule::always(Action::CursorRight),\n            ],\n        );\n    } else {\n        km.bind(key(\"right\"), Action::CursorRight);\n    }\n\n    // left: behavior at start of line\n    // accept_past_line_start takes precedence over exit_past_line_start\n    if settings.keys.accept_past_line_start {\n        km.bind_conditional(\n            key(\"left\"),\n            vec![\n                KeyRule::when(ConditionAtom::CursorAtStart, Action::ReturnSelection),\n                KeyRule::always(Action::CursorLeft),\n            ],\n        );\n    } else if settings.keys.exit_past_line_start {\n        km.bind_conditional(\n            key(\"left\"),\n            vec![\n                KeyRule::when(ConditionAtom::CursorAtStart, Action::Exit),\n                KeyRule::always(Action::CursorLeft),\n            ],\n        );\n    } else {\n        km.bind(key(\"left\"), Action::CursorLeft);\n    }\n\n    // down/up: scroll with optional exit at boundary.\n    // Non-inverted: down moves toward index 0 (can exit); up moves away (no exit).\n    // Inverted: up moves toward index 0 (can exit); down moves away (no exit).\n    let scroll_exits = settings.keys.scroll_exits;\n    let invert = settings.invert;\n    bind_scroll_key(&mut km, \"down\", Action::SelectNext, !invert, scroll_exits);\n    bind_scroll_key(&mut km, \"up\", Action::SelectPrevious, invert, scroll_exits);\n\n    // backspace: behavior at start of line\n    if settings.keys.accept_with_backspace {\n        km.bind_conditional(\n            key(\"backspace\"),\n            vec![\n                KeyRule::when(ConditionAtom::CursorAtStart, Action::ReturnSelection),\n                KeyRule::always(Action::DeleteCharBefore),\n            ],\n        );\n    } else {\n        km.bind(key(\"backspace\"), Action::DeleteCharBefore);\n    }\n\n    // --- Accept ---\n    km.bind(key(\"enter\"), accept.clone());\n    km.bind(key(\"ctrl-m\"), accept);\n\n    // --- Copy ---\n    km.bind(key(\"ctrl-y\"), Action::Copy);\n\n    // --- Numeric shortcuts (alt-1..9 by default, ctrl-1..9 if ctrl_n_shortcuts) ---\n    // These return the selection without executing, regardless of enter_accept.\n    let num_mod = if settings.ctrl_n_shortcuts {\n        \"ctrl\"\n    } else {\n        \"alt\"\n    };\n    for n in 1..=9u8 {\n        km.bind(\n            key(&format!(\"{num_mod}-{n}\")),\n            Action::ReturnSelectionNth(n),\n        );\n    }\n\n    // --- Cursor movement ---\n    km.bind(key(\"ctrl-left\"), Action::CursorWordLeft);\n    km.bind(key(\"alt-b\"), Action::CursorWordLeft);\n    km.bind(key(\"ctrl-b\"), Action::CursorLeft);\n    km.bind(key(\"ctrl-right\"), Action::CursorWordRight);\n    km.bind(key(\"alt-f\"), Action::CursorWordRight);\n    km.bind(key(\"ctrl-f\"), Action::CursorRight);\n    km.bind(key(\"home\"), Action::CursorStart);\n    // ctrl-a → CursorStart only if prefix char is NOT 'a'\n    // (otherwise ctrl-a is already bound to EnterPrefixMode above)\n    if prefix_char != 'a' {\n        km.bind(key(\"ctrl-a\"), Action::CursorStart);\n    }\n    km.bind(key(\"ctrl-e\"), Action::CursorEnd);\n    km.bind(key(\"end\"), Action::CursorEnd);\n\n    // --- Editing ---\n    km.bind(key(\"ctrl-backspace\"), Action::DeleteWordBefore);\n    km.bind(key(\"ctrl-h\"), Action::DeleteCharBefore);\n    km.bind(key(\"ctrl-?\"), Action::DeleteCharBefore);\n    km.bind(key(\"ctrl-delete\"), Action::DeleteWordAfter);\n    km.bind(key(\"delete\"), Action::DeleteCharAfter);\n    // ctrl-d: if input empty → return original, otherwise delete char\n    km.bind_conditional(\n        key(\"ctrl-d\"),\n        vec![\n            KeyRule::when(ConditionAtom::InputEmpty, Action::ReturnOriginal),\n            KeyRule::always(Action::DeleteCharAfter),\n        ],\n    );\n    km.bind(key(\"ctrl-w\"), Action::DeleteToWordBoundary);\n    km.bind(key(\"ctrl-u\"), Action::ClearLine);\n\n    // --- Search mode ---\n    km.bind(key(\"ctrl-r\"), Action::CycleFilterMode);\n    km.bind(key(\"ctrl-s\"), Action::CycleSearchMode);\n\n    // --- Scroll (no exit) ---\n    km.bind(key(\"ctrl-n\"), Action::SelectNext);\n    km.bind(key(\"ctrl-j\"), Action::SelectNext);\n    km.bind(key(\"ctrl-p\"), Action::SelectPrevious);\n    km.bind(key(\"ctrl-k\"), Action::SelectPrevious);\n\n    // --- Redraw ---\n    km.bind(key(\"ctrl-l\"), Action::Redraw);\n\n    // --- Page scroll ---\n    km.bind(key(\"pagedown\"), Action::ScrollPageDown);\n    km.bind(key(\"pageup\"), Action::ScrollPageUp);\n\n    km\n}\n\n// ---------------------------------------------------------------------------\n// Vim Normal keymap\n// ---------------------------------------------------------------------------\n\n/// Build the default vim-normal keymap.\npub fn default_vim_normal_keymap(settings: &Settings) -> Keymap {\n    let mut km = Keymap::new();\n    add_common_bindings(&mut km);\n\n    // esc / ctrl-[ → exit (vim-normal exits, unlike vim-insert)\n    km.bind(key(\"esc\"), Action::Exit);\n    km.bind(key(\"ctrl-[\"), Action::Exit);\n\n    // Prefix key\n    let prefix_char = settings.keys.prefix.chars().next().unwrap_or('a');\n    km.bind(key(&format!(\"ctrl-{prefix_char}\")), Action::EnterPrefixMode);\n\n    // --- Vim navigation ---\n    // j/k: scroll with optional exit at boundary.\n    let scroll_exits = settings.keys.scroll_exits;\n    let invert = settings.invert;\n    bind_scroll_key(&mut km, \"j\", Action::SelectNext, !invert, scroll_exits);\n    bind_scroll_key(&mut km, \"k\", Action::SelectPrevious, invert, scroll_exits);\n    km.bind(key(\"h\"), Action::CursorLeft);\n    km.bind(key(\"l\"), Action::CursorRight);\n\n    // --- Vim cursor movement ---\n    km.bind(key(\"0\"), Action::CursorStart);\n    km.bind(key(\"$\"), Action::CursorEnd);\n    km.bind(key(\"w\"), Action::CursorWordRight);\n    km.bind(key(\"b\"), Action::CursorWordLeft);\n    km.bind(key(\"e\"), Action::CursorWordEnd);\n\n    // --- Vim editing ---\n    km.bind(key(\"x\"), Action::DeleteCharAfter);\n    km.bind(key(\"d d\"), Action::ClearLine);\n    km.bind(key(\"D\"), Action::ClearToEnd);\n    km.bind(key(\"C\"), Action::VimChangeToEnd);\n\n    // --- Mode switching ---\n    km.bind(key(\"?\"), Action::VimSearchInsert);\n    km.bind(key(\"/\"), Action::VimSearchInsert);\n    km.bind(key(\"a\"), Action::VimEnterInsertAfter);\n    km.bind(key(\"A\"), Action::VimEnterInsertAtEnd);\n    km.bind(key(\"i\"), Action::VimEnterInsert);\n    km.bind(key(\"I\"), Action::VimEnterInsertAtStart);\n\n    // --- Numeric shortcuts (return selection without executing) ---\n    for n in 1..=9u8 {\n        km.bind(key(&n.to_string()), Action::ReturnSelectionNth(n));\n    }\n\n    // --- Half/full page scroll ---\n    km.bind(key(\"ctrl-u\"), Action::ScrollHalfPageUp);\n    km.bind(key(\"ctrl-d\"), Action::ScrollHalfPageDown);\n    km.bind(key(\"ctrl-b\"), Action::ScrollPageUp);\n    km.bind(key(\"ctrl-f\"), Action::ScrollPageDown);\n\n    // --- Jump ---\n    km.bind(key(\"G\"), Action::ScrollToBottom);\n    km.bind(key(\"g g\"), Action::ScrollToTop);\n    km.bind(key(\"H\"), Action::ScrollToScreenTop);\n    km.bind(key(\"M\"), Action::ScrollToScreenMiddle);\n    km.bind(key(\"L\"), Action::ScrollToScreenBottom);\n\n    // --- Arrow keys (same as emacs for convenience) ---\n    bind_scroll_key(&mut km, \"down\", Action::SelectNext, !invert, scroll_exits);\n    bind_scroll_key(&mut km, \"up\", Action::SelectPrevious, invert, scroll_exits);\n\n    // --- Page scroll ---\n    km.bind(key(\"pagedown\"), Action::ScrollPageDown);\n    km.bind(key(\"pageup\"), Action::ScrollPageUp);\n\n    // --- Accept ---\n    let accept = accept_action(settings);\n    km.bind(key(\"enter\"), accept);\n\n    km\n}\n\n// ---------------------------------------------------------------------------\n// Vim Insert keymap\n// ---------------------------------------------------------------------------\n\n/// Build the default vim-insert keymap. This clones the emacs keymap and\n/// overlays vim-insert-specific bindings (esc → enter normal mode).\npub fn default_vim_insert_keymap(settings: &Settings) -> Keymap {\n    let mut km = default_emacs_keymap(settings);\n\n    // Override esc and ctrl-[ to enter normal mode instead of exiting\n    km.bind(key(\"esc\"), Action::VimEnterNormal);\n    km.bind(key(\"ctrl-[\"), Action::VimEnterNormal);\n\n    km\n}\n\n// ---------------------------------------------------------------------------\n// Inspector keymap\n// ---------------------------------------------------------------------------\n\n/// Build the default inspector keymap (tab index 1).\n///\n/// The inspector shows details about the selected history item and has no\n/// text input, so we build a minimal keymap with only inspector-relevant\n/// bindings. We respect the user's `keymap_mode` to provide vim-style j/k\n/// navigation for vim users.\npub fn default_inspector_keymap(settings: &Settings) -> Keymap {\n    use atuin_client::settings::KeymapMode;\n\n    let mut km = Keymap::new();\n\n    // Common bindings (same as search tab)\n    km.bind(key(\"ctrl-c\"), Action::ReturnOriginal);\n    km.bind(key(\"ctrl-g\"), Action::ReturnOriginal);\n    km.bind(key(\"esc\"), Action::Exit);\n    km.bind(key(\"ctrl-[\"), Action::Exit);\n    km.bind(key(\"tab\"), Action::ReturnSelection);\n    km.bind(key(\"ctrl-o\"), Action::ToggleTab);\n\n    // Accept behavior respects enter_accept setting\n    let accept = if settings.enter_accept {\n        Action::Accept\n    } else {\n        Action::ReturnSelection\n    };\n    km.bind(key(\"enter\"), accept);\n\n    // Inspector-specific: delete history entry\n    km.bind(key(\"ctrl-d\"), Action::Delete);\n\n    // Inspector navigation\n    km.bind(key(\"up\"), Action::InspectPrevious);\n    km.bind(key(\"down\"), Action::InspectNext);\n    km.bind(key(\"pageup\"), Action::InspectPrevious);\n    km.bind(key(\"pagedown\"), Action::InspectNext);\n\n    // For vim users, add j/k navigation\n    if matches!(\n        settings.keymap_mode,\n        KeymapMode::VimNormal | KeymapMode::VimInsert\n    ) {\n        km.bind(key(\"j\"), Action::InspectNext);\n        km.bind(key(\"k\"), Action::InspectPrevious);\n    }\n\n    km\n}\n\n// ---------------------------------------------------------------------------\n// Prefix keymap\n// ---------------------------------------------------------------------------\n\n/// Build the default prefix keymap (active after ctrl-a prefix).\npub fn default_prefix_keymap() -> Keymap {\n    let mut km = Keymap::new();\n\n    km.bind(key(\"d\"), Action::Delete);\n    km.bind(key(\"a\"), Action::CursorStart);\n    km.bind_conditional(\n        key(\"c\"),\n        vec![\n            KeyRule::when(ConditionAtom::HasContext, Action::ClearContext),\n            KeyRule::always(Action::SwitchContext),\n        ],\n    );\n\n    km\n}\n\n// ---------------------------------------------------------------------------\n// KeymapSet construction\n// ---------------------------------------------------------------------------\n\n// ---------------------------------------------------------------------------\n// Config → Keymap conversion\n// ---------------------------------------------------------------------------\n\n/// Convert a `KeyBindingConfig` (from TOML) into a `KeyBinding`.\n/// Returns `Err` if an action name or condition expression is invalid.\nfn parse_binding_config(config: &KeyBindingConfig) -> Result<KeyBinding, String> {\n    match config {\n        KeyBindingConfig::Simple(action_str) => {\n            let action = Action::from_str(action_str)?;\n            Ok(KeyBinding::simple(action))\n        }\n        KeyBindingConfig::Rules(rules) => {\n            let mut parsed_rules = Vec::with_capacity(rules.len());\n            for rule_cfg in rules {\n                let action = Action::from_str(&rule_cfg.action)?;\n                let rule = match &rule_cfg.when {\n                    None => KeyRule::always(action),\n                    Some(cond_str) => {\n                        let cond = ConditionExpr::parse(cond_str)?;\n                        KeyRule::when(cond, action)\n                    }\n                };\n                parsed_rules.push(rule);\n            }\n            Ok(KeyBinding::conditional(parsed_rules))\n        }\n    }\n}\n\n/// Apply a map of key-string → binding-config overrides to a keymap.\n/// Per-key override replaces the entire rule list for that key.\n/// Invalid keys or action names are logged and skipped.\nfn apply_config_to_keymap(keymap: &mut Keymap, overrides: &HashMap<String, KeyBindingConfig>) {\n    for (key_str, binding_cfg) in overrides {\n        let key = match KeyInput::parse(key_str) {\n            Ok(k) => k,\n            Err(e) => {\n                warn!(\"invalid key in keymap config: {key_str:?}: {e}\");\n                continue;\n            }\n        };\n        match parse_binding_config(binding_cfg) {\n            Ok(binding) => {\n                keymap.bindings.insert(key, binding);\n            }\n            Err(e) => {\n                warn!(\"invalid binding for {key_str:?} in keymap config: {e}\");\n            }\n        }\n    }\n}\n\nimpl KeymapSet {\n    /// Build the complete set of default keymaps from settings.\n    pub fn defaults(settings: &Settings) -> Self {\n        KeymapSet {\n            emacs: default_emacs_keymap(settings),\n            vim_normal: default_vim_normal_keymap(settings),\n            vim_insert: default_vim_insert_keymap(settings),\n            inspector: default_inspector_keymap(settings),\n            prefix: default_prefix_keymap(),\n        }\n    }\n\n    /// Build keymaps from settings, applying any user `[keymap]` overrides.\n    ///\n    /// Precedence rules:\n    /// - If `[keymap]` has any entries, `[keys]` is **ignored entirely**.\n    ///   Defaults are built with standard `[keys]` values, then `[keymap]`\n    ///   overrides are applied per-key.\n    /// - If `[keymap]` is empty/absent, `[keys]` customizes the defaults\n    ///   (current behavior for backward compatibility).\n    pub fn from_settings(settings: &Settings) -> Self {\n        use atuin_client::settings::Keys;\n\n        if settings.keymap.is_empty() {\n            // No [keymap] section → use [keys] to customize defaults\n            Self::defaults(settings)\n        } else {\n            // [keymap] present → ignore [keys], use standard defaults as base\n            let mut base_settings = settings.clone();\n            base_settings.keys = Keys::standard_defaults();\n            let mut set = Self::defaults(&base_settings);\n            set.apply_config(settings);\n            set\n        }\n    }\n\n    /// Apply user keymap config overrides to all modes.\n    fn apply_config(&mut self, settings: &Settings) {\n        let config = &settings.keymap;\n        apply_config_to_keymap(&mut self.emacs, &config.emacs);\n        apply_config_to_keymap(&mut self.vim_normal, &config.vim_normal);\n        apply_config_to_keymap(&mut self.vim_insert, &config.vim_insert);\n        apply_config_to_keymap(&mut self.inspector, &config.inspector);\n        apply_config_to_keymap(&mut self.prefix, &config.prefix);\n    }\n}\n\n// ---------------------------------------------------------------------------\n// Tests\n// ---------------------------------------------------------------------------\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n    use crate::command::client::search::keybindings::conditions::EvalContext;\n\n    fn make_ctx(cursor: usize, width: usize, selected: usize, len: usize) -> EvalContext {\n        EvalContext {\n            cursor_position: cursor,\n            input_width: width,\n            input_byte_len: width,\n            selected_index: selected,\n            results_len: len,\n            original_input_empty: false,\n            has_context: false,\n        }\n    }\n\n    fn default_settings() -> Settings {\n        Settings::utc()\n    }\n\n    // -- Emacs keymap tests --\n\n    #[test]\n    fn emacs_ctrl_c_returns_original() {\n        let km = default_emacs_keymap(&default_settings());\n        let ctx = make_ctx(0, 0, 0, 10);\n        assert_eq!(\n            km.resolve(&key(\"ctrl-c\"), &ctx),\n            Some(Action::ReturnOriginal)\n        );\n    }\n\n    #[test]\n    fn emacs_esc_exits() {\n        let km = default_emacs_keymap(&default_settings());\n        let ctx = make_ctx(0, 0, 0, 10);\n        assert_eq!(km.resolve(&key(\"esc\"), &ctx), Some(Action::Exit));\n    }\n\n    #[test]\n    fn emacs_tab_returns_selection() {\n        // enter_accept=false in test defaults → ReturnSelection\n        let km = default_emacs_keymap(&default_settings());\n        let ctx = make_ctx(0, 0, 0, 10);\n        assert_eq!(km.resolve(&key(\"tab\"), &ctx), Some(Action::ReturnSelection));\n    }\n\n    #[test]\n    fn emacs_enter_returns_selection() {\n        // enter_accept=false in test defaults → ReturnSelection\n        let km = default_emacs_keymap(&default_settings());\n        let ctx = make_ctx(0, 0, 0, 10);\n        assert_eq!(\n            km.resolve(&key(\"enter\"), &ctx),\n            Some(Action::ReturnSelection)\n        );\n    }\n\n    #[test]\n    fn emacs_enter_accept_true_uses_accept() {\n        let mut settings = default_settings();\n        settings.enter_accept = true;\n        let km = default_emacs_keymap(&settings);\n        let ctx = make_ctx(0, 0, 0, 10);\n        assert_eq!(km.resolve(&key(\"enter\"), &ctx), Some(Action::Accept));\n        assert_eq!(km.resolve(&key(\"tab\"), &ctx), Some(Action::ReturnSelection));\n    }\n\n    #[test]\n    fn emacs_right_at_end_returns_selection() {\n        let km = default_emacs_keymap(&default_settings());\n        // cursor at end of \"hello\" (width 5)\n        let ctx = make_ctx(5, 5, 0, 10);\n        assert_eq!(\n            km.resolve(&key(\"right\"), &ctx),\n            Some(Action::ReturnSelection)\n        );\n    }\n\n    #[test]\n    fn emacs_right_not_at_end_moves() {\n        let km = default_emacs_keymap(&default_settings());\n        let ctx = make_ctx(2, 5, 0, 10);\n        assert_eq!(km.resolve(&key(\"right\"), &ctx), Some(Action::CursorRight));\n    }\n\n    #[test]\n    fn emacs_left_at_start_exits() {\n        let km = default_emacs_keymap(&default_settings());\n        let ctx = make_ctx(0, 5, 0, 10);\n        assert_eq!(km.resolve(&key(\"left\"), &ctx), Some(Action::Exit));\n    }\n\n    #[test]\n    fn emacs_left_not_at_start_moves() {\n        let km = default_emacs_keymap(&default_settings());\n        let ctx = make_ctx(3, 5, 0, 10);\n        assert_eq!(km.resolve(&key(\"left\"), &ctx), Some(Action::CursorLeft));\n    }\n\n    #[test]\n    fn emacs_down_at_start_exits() {\n        let km = default_emacs_keymap(&default_settings());\n        // selected=0 → ListAtStart → Exit\n        let ctx = make_ctx(0, 0, 0, 10);\n        assert_eq!(km.resolve(&key(\"down\"), &ctx), Some(Action::Exit));\n    }\n\n    #[test]\n    fn emacs_down_not_at_start_selects_next() {\n        let km = default_emacs_keymap(&default_settings());\n        // selected=5 → not at start → SelectNext\n        let ctx = make_ctx(0, 0, 5, 10);\n        assert_eq!(km.resolve(&key(\"down\"), &ctx), Some(Action::SelectNext));\n    }\n\n    #[test]\n    fn emacs_up_selects_previous() {\n        let km = default_emacs_keymap(&default_settings());\n        // Non-inverted: up never exits (moves away from index 0)\n        let ctx = make_ctx(0, 0, 5, 10);\n        assert_eq!(km.resolve(&key(\"up\"), &ctx), Some(Action::SelectPrevious));\n    }\n\n    #[test]\n    fn emacs_ctrl_d_empty_returns_original() {\n        let km = default_emacs_keymap(&default_settings());\n        // input empty (byte_len = 0)\n        let ctx = make_ctx(0, 0, 0, 10);\n        assert_eq!(\n            km.resolve(&key(\"ctrl-d\"), &ctx),\n            Some(Action::ReturnOriginal)\n        );\n    }\n\n    #[test]\n    fn emacs_ctrl_d_nonempty_deletes() {\n        let km = default_emacs_keymap(&default_settings());\n        let ctx = make_ctx(2, 5, 0, 10);\n        assert_eq!(\n            km.resolve(&key(\"ctrl-d\"), &ctx),\n            Some(Action::DeleteCharAfter)\n        );\n    }\n\n    #[test]\n    fn emacs_ctrl_n_selects_next_no_exit_condition() {\n        let km = default_emacs_keymap(&default_settings());\n        // at start, but ctrl-n should NOT exit (no exit condition bound)\n        let ctx = make_ctx(0, 0, 0, 10);\n        assert_eq!(km.resolve(&key(\"ctrl-n\"), &ctx), Some(Action::SelectNext));\n    }\n\n    #[test]\n    fn emacs_prefix_key_enters_prefix() {\n        let km = default_emacs_keymap(&default_settings());\n        let ctx = make_ctx(0, 0, 0, 10);\n        assert_eq!(\n            km.resolve(&key(\"ctrl-a\"), &ctx),\n            Some(Action::EnterPrefixMode)\n        );\n    }\n\n    #[test]\n    fn emacs_home_cursor_start() {\n        let km = default_emacs_keymap(&default_settings());\n        let ctx = make_ctx(5, 10, 0, 10);\n        assert_eq!(km.resolve(&key(\"home\"), &ctx), Some(Action::CursorStart));\n    }\n\n    // -- Vim Normal keymap tests --\n\n    #[test]\n    fn vim_normal_j_at_start_exits() {\n        let km = default_vim_normal_keymap(&default_settings());\n        // selected=0 → ListAtStart → Exit (non-inverted: j moves toward index 0)\n        let ctx = make_ctx(0, 0, 0, 10);\n        assert_eq!(km.resolve(&key(\"j\"), &ctx), Some(Action::Exit));\n    }\n\n    #[test]\n    fn vim_normal_j_not_at_start_selects_next() {\n        let km = default_vim_normal_keymap(&default_settings());\n        let ctx = make_ctx(0, 0, 5, 10);\n        assert_eq!(km.resolve(&key(\"j\"), &ctx), Some(Action::SelectNext));\n    }\n\n    #[test]\n    fn vim_normal_k_selects_previous() {\n        let km = default_vim_normal_keymap(&default_settings());\n        // Non-inverted: k never exits (moves away from index 0)\n        let ctx = make_ctx(0, 0, 5, 10);\n        assert_eq!(km.resolve(&key(\"k\"), &ctx), Some(Action::SelectPrevious));\n    }\n\n    #[test]\n    fn vim_normal_i_enters_insert() {\n        let km = default_vim_normal_keymap(&default_settings());\n        let ctx = make_ctx(0, 0, 0, 10);\n        assert_eq!(km.resolve(&key(\"i\"), &ctx), Some(Action::VimEnterInsert));\n    }\n\n    #[test]\n    fn vim_normal_slash_search_insert() {\n        let km = default_vim_normal_keymap(&default_settings());\n        let ctx = make_ctx(0, 0, 0, 10);\n        assert_eq!(km.resolve(&key(\"/\"), &ctx), Some(Action::VimSearchInsert));\n    }\n\n    #[test]\n    fn vim_normal_gg_scroll_to_top() {\n        let km = default_vim_normal_keymap(&default_settings());\n        let ctx = make_ctx(0, 0, 50, 100);\n        assert_eq!(km.resolve(&key(\"g g\"), &ctx), Some(Action::ScrollToTop));\n    }\n\n    #[test]\n    fn vim_normal_big_g_scroll_to_bottom() {\n        let km = default_vim_normal_keymap(&default_settings());\n        let ctx = make_ctx(0, 0, 50, 100);\n        assert_eq!(km.resolve(&key(\"G\"), &ctx), Some(Action::ScrollToBottom));\n    }\n\n    #[test]\n    fn vim_normal_numeric_returns_selection() {\n        let km = default_vim_normal_keymap(&default_settings());\n        let ctx = make_ctx(0, 0, 0, 10);\n        assert_eq!(\n            km.resolve(&key(\"3\"), &ctx),\n            Some(Action::ReturnSelectionNth(3))\n        );\n    }\n\n    #[test]\n    fn vim_normal_ctrl_u_half_page_up() {\n        let km = default_vim_normal_keymap(&default_settings());\n        let ctx = make_ctx(0, 0, 50, 100);\n        assert_eq!(\n            km.resolve(&key(\"ctrl-u\"), &ctx),\n            Some(Action::ScrollHalfPageUp)\n        );\n    }\n\n    #[test]\n    fn vim_normal_screen_jumps() {\n        let km = default_vim_normal_keymap(&default_settings());\n        let ctx = make_ctx(0, 0, 50, 100);\n        assert_eq!(km.resolve(&key(\"H\"), &ctx), Some(Action::ScrollToScreenTop));\n        assert_eq!(\n            km.resolve(&key(\"M\"), &ctx),\n            Some(Action::ScrollToScreenMiddle)\n        );\n        assert_eq!(\n            km.resolve(&key(\"L\"), &ctx),\n            Some(Action::ScrollToScreenBottom)\n        );\n    }\n\n    #[test]\n    fn vim_normal_enter_returns_selection() {\n        // enter_accept=false in test defaults → ReturnSelection\n        let km = default_vim_normal_keymap(&default_settings());\n        let ctx = make_ctx(0, 0, 0, 10);\n        assert_eq!(\n            km.resolve(&key(\"enter\"), &ctx),\n            Some(Action::ReturnSelection)\n        );\n    }\n\n    #[test]\n    fn vim_normal_enter_accept_true_uses_accept() {\n        let mut settings = default_settings();\n        settings.enter_accept = true;\n        let km = default_vim_normal_keymap(&settings);\n        let ctx = make_ctx(0, 0, 0, 10);\n        assert_eq!(km.resolve(&key(\"enter\"), &ctx), Some(Action::Accept));\n    }\n\n    // -- Vim Insert keymap tests --\n\n    #[test]\n    fn vim_insert_inherits_emacs_enter() {\n        let km = default_vim_insert_keymap(&default_settings());\n        let ctx = make_ctx(0, 0, 0, 10);\n        // enter_accept=false → ReturnSelection\n        assert_eq!(\n            km.resolve(&key(\"enter\"), &ctx),\n            Some(Action::ReturnSelection)\n        );\n    }\n\n    #[test]\n    fn vim_insert_esc_enters_normal() {\n        let km = default_vim_insert_keymap(&default_settings());\n        let ctx = make_ctx(0, 0, 0, 10);\n        assert_eq!(km.resolve(&key(\"esc\"), &ctx), Some(Action::VimEnterNormal));\n    }\n\n    #[test]\n    fn vim_insert_ctrl_bracket_enters_normal() {\n        let km = default_vim_insert_keymap(&default_settings());\n        let ctx = make_ctx(0, 0, 0, 10);\n        assert_eq!(\n            km.resolve(&key(\"ctrl-[\"), &ctx),\n            Some(Action::VimEnterNormal)\n        );\n    }\n\n    #[test]\n    fn vim_insert_inherits_emacs_ctrl_d() {\n        let km = default_vim_insert_keymap(&default_settings());\n        let ctx = make_ctx(0, 0, 0, 10);\n        // input empty → return original\n        assert_eq!(\n            km.resolve(&key(\"ctrl-d\"), &ctx),\n            Some(Action::ReturnOriginal)\n        );\n    }\n\n    // -- Inspector keymap tests --\n\n    #[test]\n    fn inspector_ctrl_d_deletes() {\n        let km = default_inspector_keymap(&default_settings());\n        let ctx = make_ctx(0, 0, 0, 10);\n        assert_eq!(km.resolve(&key(\"ctrl-d\"), &ctx), Some(Action::Delete));\n    }\n\n    #[test]\n    fn inspector_up_inspects_previous() {\n        let km = default_inspector_keymap(&default_settings());\n        let ctx = make_ctx(0, 0, 0, 10);\n        assert_eq!(km.resolve(&key(\"up\"), &ctx), Some(Action::InspectPrevious));\n    }\n\n    #[test]\n    fn inspector_down_inspects_next() {\n        let km = default_inspector_keymap(&default_settings());\n        let ctx = make_ctx(0, 0, 0, 10);\n        assert_eq!(km.resolve(&key(\"down\"), &ctx), Some(Action::InspectNext));\n    }\n\n    #[test]\n    fn inspector_esc_exits() {\n        let km = default_inspector_keymap(&default_settings());\n        let ctx = make_ctx(0, 0, 0, 10);\n        assert_eq!(km.resolve(&key(\"esc\"), &ctx), Some(Action::Exit));\n    }\n\n    #[test]\n    fn inspector_tab_returns_selection() {\n        // enter_accept=false → ReturnSelection\n        let km = default_inspector_keymap(&default_settings());\n        let ctx = make_ctx(0, 0, 0, 10);\n        assert_eq!(km.resolve(&key(\"tab\"), &ctx), Some(Action::ReturnSelection));\n    }\n\n    // -- Prefix keymap tests --\n\n    #[test]\n    fn prefix_d_deletes() {\n        let km = default_prefix_keymap();\n        let ctx = make_ctx(0, 0, 0, 10);\n        assert_eq!(km.resolve(&key(\"d\"), &ctx), Some(Action::Delete));\n    }\n\n    #[test]\n    fn prefix_a_cursor_start() {\n        let km = default_prefix_keymap();\n        let ctx = make_ctx(0, 0, 0, 10);\n        assert_eq!(km.resolve(&key(\"a\"), &ctx), Some(Action::CursorStart));\n    }\n\n    #[test]\n    fn prefix_unknown_key_returns_none() {\n        let km = default_prefix_keymap();\n        let ctx = make_ctx(0, 0, 0, 10);\n        assert_eq!(km.resolve(&key(\"x\"), &ctx), None);\n    }\n\n    // -- KeymapSet tests --\n\n    #[test]\n    fn keymap_set_defaults_builds() {\n        let settings = default_settings();\n        let set = KeymapSet::defaults(&settings);\n        let ctx = make_ctx(0, 0, 0, 10);\n\n        // Sanity check each keymap has bindings\n        assert!(set.emacs.resolve(&key(\"ctrl-c\"), &ctx).is_some());\n        assert!(set.vim_normal.resolve(&key(\"ctrl-c\"), &ctx).is_some());\n        assert!(set.vim_insert.resolve(&key(\"ctrl-c\"), &ctx).is_some());\n        assert!(set.inspector.resolve(&key(\"ctrl-c\"), &ctx).is_some());\n        assert!(set.prefix.resolve(&key(\"d\"), &ctx).is_some());\n    }\n\n    // -- Settings-dependent behavior --\n\n    #[test]\n    fn custom_prefix_char() {\n        let mut settings = default_settings();\n        settings.keys.prefix = \"x\".to_string();\n        let km = default_emacs_keymap(&settings);\n        let ctx = make_ctx(0, 0, 0, 10);\n\n        // ctrl-x should be prefix mode\n        assert_eq!(\n            km.resolve(&key(\"ctrl-x\"), &ctx),\n            Some(Action::EnterPrefixMode)\n        );\n        // ctrl-a should now be CursorStart (not prefix)\n        assert_eq!(km.resolve(&key(\"ctrl-a\"), &ctx), Some(Action::CursorStart));\n    }\n\n    #[test]\n    fn ctrl_n_shortcuts_changes_numeric_modifier() {\n        let mut settings = default_settings();\n        settings.ctrl_n_shortcuts = true;\n        let km = default_emacs_keymap(&settings);\n        let ctx = make_ctx(0, 0, 0, 10);\n\n        // ctrl-1 should work\n        assert_eq!(\n            km.resolve(&key(\"ctrl-1\"), &ctx),\n            Some(Action::ReturnSelectionNth(1))\n        );\n        // alt-1 should NOT be bound\n        assert_eq!(km.resolve(&key(\"alt-1\"), &ctx), None);\n    }\n\n    #[test]\n    fn default_alt_numeric_shortcuts() {\n        let settings = default_settings();\n        let km = default_emacs_keymap(&settings);\n        let ctx = make_ctx(0, 0, 0, 10);\n\n        // alt-1 should work by default\n        assert_eq!(\n            km.resolve(&key(\"alt-1\"), &ctx),\n            Some(Action::ReturnSelectionNth(1))\n        );\n    }\n\n    // -----------------------------------------------------------------------\n    // Config parsing and merging tests\n    // -----------------------------------------------------------------------\n\n    #[test]\n    fn parse_simple_binding_config() {\n        use atuin_client::settings::KeyBindingConfig;\n        let cfg = KeyBindingConfig::Simple(\"accept\".to_string());\n        let binding = super::parse_binding_config(&cfg).unwrap();\n        assert_eq!(binding.rules.len(), 1);\n        assert!(binding.rules[0].condition.is_none());\n        assert_eq!(binding.rules[0].action, Action::Accept);\n    }\n\n    #[test]\n    fn parse_conditional_binding_config() {\n        use atuin_client::settings::{KeyBindingConfig, KeyRuleConfig};\n        let cfg = KeyBindingConfig::Rules(vec![\n            KeyRuleConfig {\n                when: Some(\"cursor-at-start\".to_string()),\n                action: \"exit\".to_string(),\n            },\n            KeyRuleConfig {\n                when: None,\n                action: \"cursor-left\".to_string(),\n            },\n        ]);\n        let binding = super::parse_binding_config(&cfg).unwrap();\n        assert_eq!(binding.rules.len(), 2);\n        assert!(binding.rules[0].condition.is_some());\n        assert_eq!(binding.rules[0].action, Action::Exit);\n        assert!(binding.rules[1].condition.is_none());\n        assert_eq!(binding.rules[1].action, Action::CursorLeft);\n    }\n\n    #[test]\n    fn parse_binding_config_invalid_action() {\n        use atuin_client::settings::KeyBindingConfig;\n        let cfg = KeyBindingConfig::Simple(\"not-a-real-action\".to_string());\n        assert!(super::parse_binding_config(&cfg).is_err());\n    }\n\n    #[test]\n    fn parse_binding_config_invalid_condition() {\n        use atuin_client::settings::{KeyBindingConfig, KeyRuleConfig};\n        let cfg = KeyBindingConfig::Rules(vec![KeyRuleConfig {\n            when: Some(\"not-a-real-condition\".to_string()),\n            action: \"exit\".to_string(),\n        }]);\n        assert!(super::parse_binding_config(&cfg).is_err());\n    }\n\n    #[test]\n    fn config_override_replaces_key() {\n        use atuin_client::settings::KeyBindingConfig;\n        use std::collections::HashMap;\n\n        let mut settings = default_settings();\n        let set = KeymapSet::defaults(&settings);\n\n        // Default: ctrl-c → ReturnOriginal\n        let ctx = make_ctx(0, 0, 0, 10);\n        assert_eq!(\n            set.emacs.resolve(&key(\"ctrl-c\"), &ctx),\n            Some(Action::ReturnOriginal)\n        );\n\n        // Override ctrl-c → Exit via config\n        settings.keymap.emacs = HashMap::from([(\n            \"ctrl-c\".to_string(),\n            KeyBindingConfig::Simple(\"exit\".to_string()),\n        )]);\n\n        let set = KeymapSet::from_settings(&settings);\n        assert_eq!(set.emacs.resolve(&key(\"ctrl-c\"), &ctx), Some(Action::Exit));\n    }\n\n    #[test]\n    fn config_override_preserves_unoverridden_keys() {\n        use atuin_client::settings::KeyBindingConfig;\n        use std::collections::HashMap;\n\n        let mut settings = default_settings();\n        // Override only ctrl-c; enter should keep its default\n        settings.keymap.emacs = HashMap::from([(\n            \"ctrl-c\".to_string(),\n            KeyBindingConfig::Simple(\"exit\".to_string()),\n        )]);\n\n        let set = KeymapSet::from_settings(&settings);\n        let ctx = make_ctx(0, 0, 0, 10);\n\n        // ctrl-c overridden\n        assert_eq!(set.emacs.resolve(&key(\"ctrl-c\"), &ctx), Some(Action::Exit));\n        // enter still has default (enter_accept=false → ReturnSelection)\n        assert_eq!(\n            set.emacs.resolve(&key(\"enter\"), &ctx),\n            Some(Action::ReturnSelection)\n        );\n    }\n\n    #[test]\n    fn config_conditional_override() {\n        use atuin_client::settings::{KeyBindingConfig, KeyRuleConfig};\n        use std::collections::HashMap;\n\n        let mut settings = default_settings();\n        // Override \"up\" with a custom conditional\n        settings.keymap.emacs = HashMap::from([(\n            \"up\".to_string(),\n            KeyBindingConfig::Rules(vec![\n                KeyRuleConfig {\n                    when: Some(\"no-results\".to_string()),\n                    action: \"exit\".to_string(),\n                },\n                KeyRuleConfig {\n                    when: None,\n                    action: \"select-previous\".to_string(),\n                },\n            ]),\n        )]);\n\n        let set = KeymapSet::from_settings(&settings);\n\n        // With no results → exit\n        let ctx = make_ctx(0, 0, 0, 0);\n        assert_eq!(set.emacs.resolve(&key(\"up\"), &ctx), Some(Action::Exit));\n\n        // With results → select-previous\n        let ctx = make_ctx(0, 0, 0, 10);\n        assert_eq!(\n            set.emacs.resolve(&key(\"up\"), &ctx),\n            Some(Action::SelectPrevious)\n        );\n    }\n\n    #[test]\n    fn from_settings_with_empty_config_equals_defaults() {\n        let settings = default_settings();\n        let defaults = KeymapSet::defaults(&settings);\n        let from_settings = KeymapSet::from_settings(&settings);\n\n        // Verify a sample of keys produce the same results\n        let ctx = make_ctx(0, 0, 0, 10);\n        let test_keys = [\n            \"ctrl-c\", \"enter\", \"esc\", \"tab\", \"up\", \"down\", \"left\", \"right\",\n        ];\n        for k in &test_keys {\n            assert_eq!(\n                defaults.emacs.resolve(&key(k), &ctx),\n                from_settings.emacs.resolve(&key(k), &ctx),\n                \"mismatch for emacs key {k}\"\n            );\n        }\n    }\n\n    // -----------------------------------------------------------------------\n    // Phase 5: [keys] vs [keymap] backward compatibility\n    // -----------------------------------------------------------------------\n\n    #[test]\n    fn keymap_overrides_ignore_keys_section() {\n        use atuin_client::settings::KeyBindingConfig;\n\n        // Set up: [keys] disables scroll_exits, but [keymap] is present\n        let mut settings = default_settings();\n        settings.keys.scroll_exits = false;\n\n        // Without [keymap], scroll_exits=false means no exit condition on down\n        let set_legacy = KeymapSet::defaults(&settings);\n        // At list-at-start (selected=0), down should still be SelectNext (no exit)\n        let ctx_at_boundary = make_ctx(0, 0, 0, 10);\n        assert_eq!(\n            set_legacy.emacs.resolve(&key(\"down\"), &ctx_at_boundary),\n            Some(Action::SelectNext),\n            \"legacy: down at boundary should be SelectNext with scroll_exits=false\"\n        );\n\n        // With [keymap] present (even just one override), [keys] is ignored\n        // so the standard defaults (scroll_exits=true) apply\n        settings.keymap.emacs = HashMap::from([(\n            \"ctrl-c\".to_string(),\n            KeyBindingConfig::Simple(\"exit\".to_string()),\n        )]);\n        let set_keymap = KeymapSet::from_settings(&settings);\n\n        // Not at boundary (selected=5): should SelectNext normally\n        let ctx_not_at_boundary = make_ctx(0, 0, 5, 10);\n        assert_eq!(\n            set_keymap.emacs.resolve(&key(\"down\"), &ctx_not_at_boundary),\n            Some(Action::SelectNext),\n            \"keymap: down not at boundary should SelectNext\"\n        );\n        // At list-at-start (selected=0): should Exit (standard scroll_exits=true)\n        assert_eq!(\n            set_keymap.emacs.resolve(&key(\"down\"), &ctx_at_boundary),\n            Some(Action::Exit),\n            \"keymap: down at boundary should Exit (standard defaults restored)\"\n        );\n    }\n\n    #[test]\n    fn keymap_present_resets_to_standard_keys_defaults() {\n        use atuin_client::settings::KeyBindingConfig;\n\n        let mut settings = default_settings();\n        // Disable all [keys] behaviors\n        settings.keys.exit_past_line_start = false;\n        settings.keys.accept_past_line_end = false;\n\n        // Without [keymap], left should be plain CursorLeft\n        let set_legacy = KeymapSet::defaults(&settings);\n        let ctx_at_start = make_ctx(0, 5, 0, 10);\n        assert_eq!(\n            set_legacy.emacs.resolve(&key(\"left\"), &ctx_at_start),\n            Some(Action::CursorLeft),\n            \"legacy: left should be plain CursorLeft without exit_past_line_start\"\n        );\n\n        // Add a [keymap] entry (for a different key)\n        settings.keymap.emacs = HashMap::from([(\n            \"ctrl-c\".to_string(),\n            KeyBindingConfig::Simple(\"exit\".to_string()),\n        )]);\n        let set_keymap = KeymapSet::from_settings(&settings);\n\n        // Now left should use standard defaults (exit_past_line_start=true)\n        // At cursor start → Exit\n        assert_eq!(\n            set_keymap.emacs.resolve(&key(\"left\"), &ctx_at_start),\n            Some(Action::Exit),\n            \"keymap: left at cursor start should exit (standard defaults)\"\n        );\n\n        // Right at cursor end should return selection (standard defaults: accept_past_line_end=true, enter_accept=false)\n        let ctx_at_end = make_ctx(5, 5, 0, 10);\n        assert_eq!(\n            set_keymap.emacs.resolve(&key(\"right\"), &ctx_at_end),\n            Some(Action::ReturnSelection),\n            \"keymap: right at cursor end should return selection (standard defaults)\"\n        );\n    }\n\n    #[test]\n    fn keys_has_non_default_values_detection() {\n        use atuin_client::settings::Keys;\n\n        let standard = Keys::standard_defaults();\n        assert!(!standard.has_non_default_values());\n\n        let mut modified = Keys::standard_defaults();\n        modified.scroll_exits = false;\n        assert!(modified.has_non_default_values());\n\n        let mut modified = Keys::standard_defaults();\n        modified.prefix = \"x\".to_string();\n        assert!(modified.has_non_default_values());\n    }\n\n    #[test]\n    fn original_input_empty_condition_in_config() {\n        use atuin_client::settings::{KeyBindingConfig, KeyRuleConfig};\n        use std::collections::HashMap;\n\n        let mut settings = default_settings();\n        // Configure esc to: if original-input-empty -> return-query, else return-original\n        settings.keymap.emacs = HashMap::from([(\n            \"esc\".to_string(),\n            KeyBindingConfig::Rules(vec![\n                KeyRuleConfig {\n                    when: Some(\"original-input-empty\".to_string()),\n                    action: \"return-query\".to_string(),\n                },\n                KeyRuleConfig {\n                    when: None,\n                    action: \"return-original\".to_string(),\n                },\n            ]),\n        )]);\n\n        let set = KeymapSet::from_settings(&settings);\n\n        // When original input was empty, should return-query\n        let ctx_original_empty = EvalContext {\n            cursor_position: 0,\n            input_width: 5,\n            input_byte_len: 5,\n            selected_index: 0,\n            results_len: 10,\n            original_input_empty: true,\n            has_context: false,\n        };\n        assert_eq!(\n            set.emacs.resolve(&key(\"esc\"), &ctx_original_empty),\n            Some(Action::ReturnQuery),\n            \"esc with original_input_empty=true should return-query\"\n        );\n\n        // When original input was not empty, should return-original\n        let ctx_original_not_empty = EvalContext {\n            cursor_position: 0,\n            input_width: 5,\n            input_byte_len: 5,\n            selected_index: 0,\n            results_len: 10,\n            original_input_empty: false,\n            has_context: false,\n        };\n        assert_eq!(\n            set.emacs.resolve(&key(\"esc\"), &ctx_original_not_empty),\n            Some(Action::ReturnOriginal),\n            \"esc with original_input_empty=false should return-original\"\n        );\n    }\n}\n"
  },
  {
    "path": "crates/atuin/src/command/client/search/keybindings/key.rs",
    "content": "use std::fmt;\n\nuse ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MediaKeyCode};\nuse serde::{Deserialize, Deserializer, Serialize, Serializer};\n\n/// A single key press with modifiers (e.g. `ctrl-c`, `alt-f`, `enter`).\n#[derive(Debug, Clone, PartialEq, Eq, Hash)]\n#[allow(clippy::struct_excessive_bools)]\npub struct SingleKey {\n    pub code: KeyCodeValue,\n    pub ctrl: bool,\n    pub alt: bool,\n    pub shift: bool,\n    pub super_key: bool,\n}\n\n/// The key code portion of a key press.\n#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]\npub enum KeyCodeValue {\n    Char(char),\n    Enter,\n    Esc,\n    Tab,\n    Backspace,\n    Delete,\n    Insert,\n    Up,\n    Down,\n    Left,\n    Right,\n    Home,\n    End,\n    PageUp,\n    PageDown,\n    Space,\n    F(u8),\n    Media(MediaKeyCode),\n}\n\n/// A key input that may be a single key or a multi-key sequence (e.g. `g g`).\n#[derive(Debug, Clone, PartialEq, Eq, Hash)]\npub enum KeyInput {\n    Single(SingleKey),\n    Sequence(Vec<SingleKey>),\n}\n\nimpl SingleKey {\n    /// Convert a crossterm `KeyEvent` into a `SingleKey`.\n    pub fn from_event(event: &KeyEvent) -> Option<Self> {\n        let ctrl = event.modifiers.contains(KeyModifiers::CONTROL);\n        let alt = event.modifiers.contains(KeyModifiers::ALT);\n        let shift = event.modifiers.contains(KeyModifiers::SHIFT);\n        let super_key = event.modifiers.contains(KeyModifiers::SUPER);\n\n        let code = match event.code {\n            KeyCode::Char(' ') => KeyCodeValue::Space,\n            KeyCode::Char(c) => {\n                // If shift is the only modifier and it's an uppercase letter,\n                // we store the uppercase char directly and clear the shift flag\n                // since the case already encodes it.\n                if shift && !ctrl && !alt && !super_key && c.is_ascii_uppercase() {\n                    return Some(SingleKey {\n                        code: KeyCodeValue::Char(c),\n                        ctrl: false,\n                        alt: false,\n                        shift: false,\n                        super_key: false,\n                    });\n                }\n                KeyCodeValue::Char(c)\n            }\n            KeyCode::Enter => KeyCodeValue::Enter,\n            KeyCode::Esc => KeyCodeValue::Esc,\n            KeyCode::Tab => KeyCodeValue::Tab,\n            // BackTab is sent by many terminals for Shift+Tab\n            KeyCode::BackTab => {\n                return Some(SingleKey {\n                    code: KeyCodeValue::Tab,\n                    ctrl,\n                    alt,\n                    shift: true,\n                    super_key,\n                });\n            }\n            KeyCode::Backspace => KeyCodeValue::Backspace,\n            KeyCode::Delete => KeyCodeValue::Delete,\n            KeyCode::Insert => KeyCodeValue::Insert,\n            KeyCode::Up => KeyCodeValue::Up,\n            KeyCode::Down => KeyCodeValue::Down,\n            KeyCode::Left => KeyCodeValue::Left,\n            KeyCode::Right => KeyCodeValue::Right,\n            KeyCode::Home => KeyCodeValue::Home,\n            KeyCode::End => KeyCodeValue::End,\n            KeyCode::PageUp => KeyCodeValue::PageUp,\n            KeyCode::PageDown => KeyCodeValue::PageDown,\n            KeyCode::F(n) => KeyCodeValue::F(n),\n            KeyCode::Media(m) => KeyCodeValue::Media(m),\n            _ => return None,\n        };\n\n        Some(SingleKey {\n            code,\n            ctrl,\n            alt,\n            shift: if matches!(code, KeyCodeValue::Char(_)) {\n                false\n            } else {\n                shift\n            },\n            super_key,\n        })\n    }\n\n    /// Parse a key string like `\"ctrl-c\"`, `\"alt-f\"`, `\"enter\"`, `\"G\"`.\n    pub fn parse(s: &str) -> Result<Self, String> {\n        let s = s.trim();\n        let parts: Vec<&str> = s.split('-').collect();\n\n        let mut ctrl = false;\n        let mut alt = false;\n        let mut shift = false;\n        let mut super_key = false;\n\n        // All parts except the last are modifiers\n        for &part in &parts[..parts.len() - 1] {\n            match part.to_lowercase().as_str() {\n                \"ctrl\" => ctrl = true,\n                \"alt\" => alt = true,\n                \"shift\" => shift = true,\n                \"super\" | \"cmd\" | \"win\" => super_key = true,\n                _ => return Err(format!(\"unknown modifier: {part}\")),\n            }\n        }\n\n        let key_part = parts[parts.len() - 1];\n        let code = match key_part.to_lowercase().as_str() {\n            \"enter\" | \"return\" => KeyCodeValue::Enter,\n            \"esc\" | \"escape\" => KeyCodeValue::Esc,\n            \"tab\" => KeyCodeValue::Tab,\n            \"backspace\" => KeyCodeValue::Backspace,\n            \"delete\" | \"del\" => KeyCodeValue::Delete,\n            \"insert\" | \"ins\" => KeyCodeValue::Insert,\n            \"up\" => KeyCodeValue::Up,\n            \"down\" => KeyCodeValue::Down,\n            \"left\" => KeyCodeValue::Left,\n            \"right\" => KeyCodeValue::Right,\n            \"home\" => KeyCodeValue::Home,\n            \"end\" => KeyCodeValue::End,\n            \"pageup\" => KeyCodeValue::PageUp,\n            \"pagedown\" => KeyCodeValue::PageDown,\n            \"space\" => KeyCodeValue::Space,\n            s if s.starts_with('f') && s.len() > 1 => {\n                // Parse function keys like \"f1\", \"f12\"\n                if let Ok(n) = s[1..].parse::<u8>() {\n                    if (1..=24).contains(&n) {\n                        KeyCodeValue::F(n)\n                    } else {\n                        return Err(format!(\"function key out of range: {key_part}\"));\n                    }\n                } else {\n                    return Err(format!(\"unknown key: {key_part}\"));\n                }\n            }\n            \"[\" => KeyCodeValue::Char('['),\n            \"]\" => KeyCodeValue::Char(']'),\n            \"?\" => KeyCodeValue::Char('?'),\n            \"/\" => KeyCodeValue::Char('/'),\n            \"$\" => KeyCodeValue::Char('$'),\n            // Media keys (no dashes - the parser splits on dash for modifiers)\n            \"play\" => KeyCodeValue::Media(MediaKeyCode::Play),\n            \"pause\" => KeyCodeValue::Media(MediaKeyCode::Pause),\n            \"playpause\" => KeyCodeValue::Media(MediaKeyCode::PlayPause),\n            \"stop\" => KeyCodeValue::Media(MediaKeyCode::Stop),\n            \"fastforward\" => KeyCodeValue::Media(MediaKeyCode::FastForward),\n            \"rewind\" => KeyCodeValue::Media(MediaKeyCode::Rewind),\n            \"tracknext\" => KeyCodeValue::Media(MediaKeyCode::TrackNext),\n            \"trackprevious\" => KeyCodeValue::Media(MediaKeyCode::TrackPrevious),\n            \"record\" => KeyCodeValue::Media(MediaKeyCode::Record),\n            \"lowervolume\" => KeyCodeValue::Media(MediaKeyCode::LowerVolume),\n            \"raisevolume\" => KeyCodeValue::Media(MediaKeyCode::RaiseVolume),\n            \"mutevolume\" | \"mute\" => KeyCodeValue::Media(MediaKeyCode::MuteVolume),\n            _ => {\n                let chars: Vec<char> = key_part.chars().collect();\n                if chars.len() == 1 {\n                    let c = chars[0];\n                    // An uppercase letter implies shift (unless shift already specified)\n                    if c.is_ascii_uppercase() && !ctrl && !alt && !super_key {\n                        return Ok(SingleKey {\n                            code: KeyCodeValue::Char(c),\n                            ctrl: false,\n                            alt: false,\n                            shift: false,\n                            super_key: false,\n                        });\n                    }\n                    KeyCodeValue::Char(c)\n                } else {\n                    return Err(format!(\"unknown key: {key_part}\"));\n                }\n            }\n        };\n\n        Ok(SingleKey {\n            code,\n            ctrl,\n            alt,\n            shift,\n            super_key,\n        })\n    }\n}\n\nimpl fmt::Display for SingleKey {\n    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {\n        if self.super_key {\n            write!(f, \"super-\")?;\n        }\n        if self.ctrl {\n            write!(f, \"ctrl-\")?;\n        }\n        if self.alt {\n            write!(f, \"alt-\")?;\n        }\n        if self.shift {\n            write!(f, \"shift-\")?;\n        }\n        match &self.code {\n            KeyCodeValue::Char(c) => write!(f, \"{c}\"),\n            KeyCodeValue::Enter => write!(f, \"enter\"),\n            KeyCodeValue::Esc => write!(f, \"esc\"),\n            KeyCodeValue::Tab => write!(f, \"tab\"),\n            KeyCodeValue::Backspace => write!(f, \"backspace\"),\n            KeyCodeValue::Delete => write!(f, \"delete\"),\n            KeyCodeValue::Insert => write!(f, \"insert\"),\n            KeyCodeValue::Up => write!(f, \"up\"),\n            KeyCodeValue::Down => write!(f, \"down\"),\n            KeyCodeValue::Left => write!(f, \"left\"),\n            KeyCodeValue::Right => write!(f, \"right\"),\n            KeyCodeValue::Home => write!(f, \"home\"),\n            KeyCodeValue::End => write!(f, \"end\"),\n            KeyCodeValue::PageUp => write!(f, \"pageup\"),\n            KeyCodeValue::PageDown => write!(f, \"pagedown\"),\n            KeyCodeValue::Space => write!(f, \"space\"),\n            KeyCodeValue::F(n) => write!(f, \"f{n}\"),\n            KeyCodeValue::Media(m) => match m {\n                MediaKeyCode::Play => write!(f, \"play\"),\n                MediaKeyCode::Pause => write!(f, \"media-pause\"),\n                MediaKeyCode::PlayPause => write!(f, \"playpause\"),\n                MediaKeyCode::Stop => write!(f, \"stop\"),\n                MediaKeyCode::FastForward => write!(f, \"fastforward\"),\n                MediaKeyCode::Rewind => write!(f, \"rewind\"),\n                MediaKeyCode::TrackNext => write!(f, \"tracknext\"),\n                MediaKeyCode::TrackPrevious => write!(f, \"trackprevious\"),\n                MediaKeyCode::Record => write!(f, \"record\"),\n                MediaKeyCode::LowerVolume => write!(f, \"lowervolume\"),\n                MediaKeyCode::RaiseVolume => write!(f, \"raisevolume\"),\n                MediaKeyCode::MuteVolume => write!(f, \"mutevolume\"),\n                MediaKeyCode::Reverse => write!(f, \"reverse\"),\n            },\n        }\n    }\n}\n\nimpl KeyInput {\n    /// Parse a key input string. Supports multi-key sequences separated by spaces\n    /// (e.g. `\"g g\"`).\n    pub fn parse(s: &str) -> Result<Self, String> {\n        let s = s.trim();\n        // Check for space-separated multi-key sequences\n        // But don't split \"space\" or modifier combos like \"ctrl-a\"\n        let parts: Vec<&str> = s.split_whitespace().collect();\n        if parts.len() > 1 {\n            let keys: Result<Vec<SingleKey>, String> =\n                parts.iter().map(|p| SingleKey::parse(p)).collect();\n            Ok(KeyInput::Sequence(keys?))\n        } else {\n            Ok(KeyInput::Single(SingleKey::parse(s)?))\n        }\n    }\n}\n\nimpl fmt::Display for KeyInput {\n    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {\n        match self {\n            KeyInput::Single(k) => write!(f, \"{k}\"),\n            KeyInput::Sequence(keys) => {\n                for (i, k) in keys.iter().enumerate() {\n                    if i > 0 {\n                        write!(f, \" \")?;\n                    }\n                    write!(f, \"{k}\")?;\n                }\n                Ok(())\n            }\n        }\n    }\n}\n\nimpl Serialize for KeyInput {\n    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {\n        serializer.serialize_str(&self.to_string())\n    }\n}\n\nimpl<'de> Deserialize<'de> for KeyInput {\n    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {\n        let s = String::deserialize(deserializer)?;\n        KeyInput::parse(&s).map_err(serde::de::Error::custom)\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n    use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};\n\n    #[test]\n    fn parse_simple_keys() {\n        let k = SingleKey::parse(\"a\").unwrap();\n        assert_eq!(k.code, KeyCodeValue::Char('a'));\n        assert!(!k.ctrl && !k.alt && !k.shift);\n\n        let k = SingleKey::parse(\"enter\").unwrap();\n        assert_eq!(k.code, KeyCodeValue::Enter);\n\n        let k = SingleKey::parse(\"esc\").unwrap();\n        assert_eq!(k.code, KeyCodeValue::Esc);\n\n        let k = SingleKey::parse(\"tab\").unwrap();\n        assert_eq!(k.code, KeyCodeValue::Tab);\n\n        let k = SingleKey::parse(\"space\").unwrap();\n        assert_eq!(k.code, KeyCodeValue::Space);\n    }\n\n    #[test]\n    fn parse_modifiers() {\n        let k = SingleKey::parse(\"ctrl-c\").unwrap();\n        assert_eq!(k.code, KeyCodeValue::Char('c'));\n        assert!(k.ctrl);\n        assert!(!k.alt);\n\n        let k = SingleKey::parse(\"alt-f\").unwrap();\n        assert_eq!(k.code, KeyCodeValue::Char('f'));\n        assert!(k.alt);\n        assert!(!k.ctrl);\n\n        let k = SingleKey::parse(\"ctrl-alt-x\").unwrap();\n        assert_eq!(k.code, KeyCodeValue::Char('x'));\n        assert!(k.ctrl && k.alt);\n    }\n\n    #[test]\n    fn parse_uppercase_implies_no_shift_flag() {\n        let k = SingleKey::parse(\"G\").unwrap();\n        assert_eq!(k.code, KeyCodeValue::Char('G'));\n        assert!(!k.shift);\n        assert!(!k.ctrl);\n    }\n\n    #[test]\n    fn parse_special_chars() {\n        let k = SingleKey::parse(\"ctrl-[\").unwrap();\n        assert_eq!(k.code, KeyCodeValue::Char('['));\n        assert!(k.ctrl);\n\n        let k = SingleKey::parse(\"?\").unwrap();\n        assert_eq!(k.code, KeyCodeValue::Char('?'));\n\n        let k = SingleKey::parse(\"/\").unwrap();\n        assert_eq!(k.code, KeyCodeValue::Char('/'));\n    }\n\n    #[test]\n    fn parse_multi_key_sequence() {\n        let ki = KeyInput::parse(\"g g\").unwrap();\n        match ki {\n            KeyInput::Sequence(keys) => {\n                assert_eq!(keys.len(), 2);\n                assert_eq!(keys[0].code, KeyCodeValue::Char('g'));\n                assert_eq!(keys[1].code, KeyCodeValue::Char('g'));\n            }\n            _ => panic!(\"expected sequence\"),\n        }\n    }\n\n    #[test]\n    fn display_round_trip() {\n        let cases = [\"ctrl-c\", \"alt-f\", \"enter\", \"G\", \"tab\", \"pageup\"];\n        for s in cases {\n            let k = KeyInput::parse(s).unwrap();\n            let display = k.to_string();\n            let k2 = KeyInput::parse(&display).unwrap();\n            assert_eq!(k, k2, \"round-trip failed for {s}\");\n        }\n\n        let ki = KeyInput::parse(\"g g\").unwrap();\n        assert_eq!(ki.to_string(), \"g g\");\n    }\n\n    #[test]\n    fn from_event_basic() {\n        let event = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL);\n        let k = SingleKey::from_event(&event).unwrap();\n        assert_eq!(k.code, KeyCodeValue::Char('c'));\n        assert!(k.ctrl);\n        assert!(!k.alt);\n\n        let event = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);\n        let k = SingleKey::from_event(&event).unwrap();\n        assert_eq!(k.code, KeyCodeValue::Enter);\n    }\n\n    #[test]\n    fn from_event_uppercase() {\n        // Crossterm sends uppercase chars with SHIFT modifier\n        let event = KeyEvent::new(KeyCode::Char('G'), KeyModifiers::SHIFT);\n        let k = SingleKey::from_event(&event).unwrap();\n        assert_eq!(k.code, KeyCodeValue::Char('G'));\n        // shift flag should be cleared since the case encodes it\n        assert!(!k.shift);\n    }\n\n    #[test]\n    fn from_event_matches_parsed() {\n        // Verify that from_event and parse produce the same SingleKey\n        let event = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL);\n        let from_event = SingleKey::from_event(&event).unwrap();\n        let parsed = SingleKey::parse(\"ctrl-c\").unwrap();\n        assert_eq!(from_event, parsed);\n\n        let event = KeyEvent::new(KeyCode::Char('G'), KeyModifiers::SHIFT);\n        let from_event = SingleKey::from_event(&event).unwrap();\n        let parsed = SingleKey::parse(\"G\").unwrap();\n        assert_eq!(from_event, parsed);\n    }\n\n    #[test]\n    fn parse_super_modifier() {\n        let k = SingleKey::parse(\"super-a\").unwrap();\n        assert_eq!(k.code, KeyCodeValue::Char('a'));\n        assert!(k.super_key);\n        assert!(!k.ctrl && !k.alt && !k.shift);\n\n        // \"cmd\" is an alias for \"super\"\n        let k2 = SingleKey::parse(\"cmd-a\").unwrap();\n        assert_eq!(k, k2);\n\n        // \"win\" is an alias for \"super\"\n        let k3 = SingleKey::parse(\"win-a\").unwrap();\n        assert_eq!(k, k3);\n    }\n\n    #[test]\n    fn parse_super_with_other_modifiers() {\n        let k = SingleKey::parse(\"super-ctrl-c\").unwrap();\n        assert_eq!(k.code, KeyCodeValue::Char('c'));\n        assert!(k.super_key && k.ctrl);\n        assert!(!k.alt && !k.shift);\n    }\n\n    #[test]\n    fn display_super_modifier() {\n        let k = SingleKey::parse(\"super-a\").unwrap();\n        assert_eq!(k.to_string(), \"super-a\");\n\n        let k = SingleKey::parse(\"super-ctrl-x\").unwrap();\n        assert_eq!(k.to_string(), \"super-ctrl-x\");\n    }\n\n    #[test]\n    fn display_round_trip_super() {\n        let k = KeyInput::parse(\"super-a\").unwrap();\n        let display = k.to_string();\n        let k2 = KeyInput::parse(&display).unwrap();\n        assert_eq!(k, k2, \"round-trip failed for super-a\");\n    }\n\n    #[test]\n    fn from_event_super() {\n        let event = KeyEvent::new(KeyCode::Char('a'), KeyModifiers::SUPER);\n        let k = SingleKey::from_event(&event).unwrap();\n        assert_eq!(k.code, KeyCodeValue::Char('a'));\n        assert!(k.super_key);\n        assert!(!k.ctrl && !k.alt && !k.shift);\n    }\n\n    #[test]\n    fn from_event_super_matches_parsed() {\n        let event = KeyEvent::new(KeyCode::Char('a'), KeyModifiers::SUPER);\n        let from_event = SingleKey::from_event(&event).unwrap();\n        let parsed = SingleKey::parse(\"super-a\").unwrap();\n        assert_eq!(from_event, parsed);\n    }\n\n    #[test]\n    fn super_uppercase_preserves_super() {\n        // super-G should keep the super flag (unlike bare \"G\" which clears shift)\n        let k = SingleKey::parse(\"super-G\").unwrap();\n        assert_eq!(k.code, KeyCodeValue::Char('G'));\n        assert!(k.super_key);\n    }\n\n    #[test]\n    fn parse_errors() {\n        assert!(SingleKey::parse(\"ctrl-alt-shift-xxx\").is_err());\n        assert!(SingleKey::parse(\"foobar-a\").is_err());\n    }\n\n    #[test]\n    fn parse_function_keys() {\n        let k = SingleKey::parse(\"f1\").unwrap();\n        assert_eq!(k.code, KeyCodeValue::F(1));\n        assert!(!k.ctrl && !k.alt && !k.shift);\n\n        let k = SingleKey::parse(\"F12\").unwrap();\n        assert_eq!(k.code, KeyCodeValue::F(12));\n\n        let k = SingleKey::parse(\"ctrl-f5\").unwrap();\n        assert_eq!(k.code, KeyCodeValue::F(5));\n        assert!(k.ctrl);\n\n        // F24 is valid (some keyboards have extended function keys)\n        let k = SingleKey::parse(\"f24\").unwrap();\n        assert_eq!(k.code, KeyCodeValue::F(24));\n\n        // F0 and F25+ are invalid\n        assert!(SingleKey::parse(\"f0\").is_err());\n        assert!(SingleKey::parse(\"f25\").is_err());\n    }\n\n    #[test]\n    fn from_event_function_keys() {\n        let event = KeyEvent::new(KeyCode::F(1), KeyModifiers::NONE);\n        let k = SingleKey::from_event(&event).unwrap();\n        assert_eq!(k.code, KeyCodeValue::F(1));\n\n        let event = KeyEvent::new(KeyCode::F(12), KeyModifiers::CONTROL);\n        let k = SingleKey::from_event(&event).unwrap();\n        assert_eq!(k.code, KeyCodeValue::F(12));\n        assert!(k.ctrl);\n    }\n\n    #[test]\n    fn display_function_keys() {\n        let k = SingleKey::parse(\"f1\").unwrap();\n        assert_eq!(k.to_string(), \"f1\");\n\n        let k = SingleKey::parse(\"ctrl-f12\").unwrap();\n        assert_eq!(k.to_string(), \"ctrl-f12\");\n    }\n\n    #[test]\n    fn function_key_round_trip() {\n        let cases = [\"f1\", \"f12\", \"ctrl-f5\", \"alt-f10\"];\n        for s in cases {\n            let k = KeyInput::parse(s).unwrap();\n            let display = k.to_string();\n            let k2 = KeyInput::parse(&display).unwrap();\n            assert_eq!(k, k2, \"round-trip failed for {s}\");\n        }\n    }\n\n    #[test]\n    fn from_event_function_key_matches_parsed() {\n        let event = KeyEvent::new(KeyCode::F(12), KeyModifiers::NONE);\n        let from_event = SingleKey::from_event(&event).unwrap();\n        let parsed = SingleKey::parse(\"f12\").unwrap();\n        assert_eq!(from_event, parsed);\n    }\n\n    #[test]\n    fn from_event_backtab_becomes_shift_tab() {\n        // Many terminals send BackTab for Shift+Tab\n        let event = KeyEvent::new(KeyCode::BackTab, KeyModifiers::NONE);\n        let k = SingleKey::from_event(&event).unwrap();\n        assert_eq!(k.code, KeyCodeValue::Tab);\n        assert!(k.shift);\n        assert!(!k.ctrl && !k.alt);\n    }\n\n    #[test]\n    fn from_event_backtab_matches_parsed_shift_tab() {\n        let event = KeyEvent::new(KeyCode::BackTab, KeyModifiers::NONE);\n        let from_event = SingleKey::from_event(&event).unwrap();\n        let parsed = SingleKey::parse(\"shift-tab\").unwrap();\n        assert_eq!(from_event, parsed);\n    }\n\n    #[test]\n    fn from_event_backtab_with_ctrl() {\n        // BackTab with ctrl modifier\n        let event = KeyEvent::new(KeyCode::BackTab, KeyModifiers::CONTROL);\n        let k = SingleKey::from_event(&event).unwrap();\n        assert_eq!(k.code, KeyCodeValue::Tab);\n        assert!(k.shift);\n        assert!(k.ctrl);\n    }\n\n    #[test]\n    fn parse_insert_key() {\n        let k = SingleKey::parse(\"insert\").unwrap();\n        assert_eq!(k.code, KeyCodeValue::Insert);\n        assert!(!k.ctrl && !k.alt && !k.shift);\n\n        let k = SingleKey::parse(\"ins\").unwrap();\n        assert_eq!(k.code, KeyCodeValue::Insert);\n\n        let k = SingleKey::parse(\"ctrl-insert\").unwrap();\n        assert_eq!(k.code, KeyCodeValue::Insert);\n        assert!(k.ctrl);\n    }\n\n    #[test]\n    fn from_event_insert_key() {\n        let event = KeyEvent::new(KeyCode::Insert, KeyModifiers::NONE);\n        let k = SingleKey::from_event(&event).unwrap();\n        assert_eq!(k.code, KeyCodeValue::Insert);\n    }\n\n    #[test]\n    fn insert_key_round_trip() {\n        let k = KeyInput::parse(\"insert\").unwrap();\n        let display = k.to_string();\n        assert_eq!(display, \"insert\");\n        let k2 = KeyInput::parse(&display).unwrap();\n        assert_eq!(k, k2);\n    }\n}\n"
  },
  {
    "path": "crates/atuin/src/command/client/search/keybindings/keymap.rs",
    "content": "use std::collections::HashMap;\n\nuse super::actions::Action;\nuse super::conditions::{ConditionExpr, EvalContext};\nuse super::key::{KeyInput, SingleKey};\n\n/// A single rule within a keybinding: an optional condition and an action.\n/// If the condition is `None`, the rule always matches.\n#[derive(Debug, Clone)]\npub struct KeyRule {\n    pub condition: Option<ConditionExpr>,\n    pub action: Action,\n}\n\n/// A keybinding is an ordered list of rules. The first rule whose condition\n/// matches (or has no condition) wins.\n#[derive(Debug, Clone)]\npub struct KeyBinding {\n    pub rules: Vec<KeyRule>,\n}\n\n/// A keymap is a collection of keybindings indexed by key input.\n#[derive(Debug, Clone)]\npub struct Keymap {\n    pub bindings: HashMap<KeyInput, KeyBinding>,\n}\n\nimpl KeyRule {\n    /// Create an unconditional rule.\n    pub fn always(action: Action) -> Self {\n        KeyRule {\n            condition: None,\n            action,\n        }\n    }\n\n    /// Create a conditional rule. Accepts any type convertible to `ConditionExpr`,\n    /// including bare `ConditionAtom` values.\n    pub fn when(condition: impl Into<ConditionExpr>, action: Action) -> Self {\n        KeyRule {\n            condition: Some(condition.into()),\n            action,\n        }\n    }\n}\n\nimpl KeyBinding {\n    /// Create a simple (unconditional) binding.\n    pub fn simple(action: Action) -> Self {\n        KeyBinding {\n            rules: vec![KeyRule::always(action)],\n        }\n    }\n\n    /// Create a conditional binding from a list of rules.\n    pub fn conditional(rules: Vec<KeyRule>) -> Self {\n        KeyBinding { rules }\n    }\n}\n\nimpl Keymap {\n    /// Create an empty keymap.\n    pub fn new() -> Self {\n        Keymap {\n            bindings: HashMap::new(),\n        }\n    }\n\n    /// Bind a key input to a simple (unconditional) action.\n    pub fn bind(&mut self, key: KeyInput, action: Action) {\n        self.bindings.insert(key, KeyBinding::simple(action));\n    }\n\n    /// Bind a key input to a conditional set of rules.\n    pub fn bind_conditional(&mut self, key: KeyInput, rules: Vec<KeyRule>) {\n        self.bindings.insert(key, KeyBinding::conditional(rules));\n    }\n\n    /// Resolve a key input to an action given the current evaluation context.\n    /// Returns `None` if the key has no binding or no rule's condition matches.\n    pub fn resolve(&self, key: &KeyInput, ctx: &EvalContext) -> Option<Action> {\n        let binding = self.bindings.get(key)?;\n        for rule in &binding.rules {\n            match &rule.condition {\n                None => return Some(rule.action.clone()),\n                Some(cond) if cond.evaluate(ctx) => return Some(rule.action.clone()),\n                Some(_) => {}\n            }\n        }\n        None\n    }\n\n    /// Check if any binding starts with the given single key as the first key\n    /// of a multi-key sequence. Used to detect pending multi-key sequences.\n    pub fn has_sequence_starting_with(&self, prefix: &SingleKey) -> bool {\n        self.bindings.keys().any(|ki| match ki {\n            KeyInput::Sequence(keys) => keys.first() == Some(prefix),\n            KeyInput::Single(_) => false,\n        })\n    }\n\n    /// Merge another keymap into this one. Keys from `other` override keys in `self`.\n    #[allow(dead_code)]\n    pub fn merge(&mut self, other: &Keymap) {\n        for (key, binding) in &other.bindings {\n            self.bindings.insert(key.clone(), binding.clone());\n        }\n    }\n}\n\nimpl Default for Keymap {\n    fn default() -> Self {\n        Self::new()\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::super::conditions::ConditionAtom;\n    use super::*;\n\n    fn make_ctx(cursor: usize, width: usize, selected: usize, len: usize) -> EvalContext {\n        EvalContext {\n            cursor_position: cursor,\n            input_width: width,\n            input_byte_len: width,\n            selected_index: selected,\n            results_len: len,\n            original_input_empty: false,\n            has_context: false,\n        }\n    }\n\n    #[test]\n    fn simple_binding_resolves() {\n        let mut keymap = Keymap::new();\n        let key = KeyInput::parse(\"ctrl-c\").unwrap();\n        keymap.bind(key.clone(), Action::ReturnOriginal);\n\n        let ctx = make_ctx(0, 0, 0, 10);\n        assert_eq!(keymap.resolve(&key, &ctx), Some(Action::ReturnOriginal));\n    }\n\n    #[test]\n    fn conditional_first_match_wins() {\n        let mut keymap = Keymap::new();\n        let key = KeyInput::parse(\"left\").unwrap();\n        keymap.bind_conditional(\n            key.clone(),\n            vec![\n                KeyRule::when(ConditionAtom::CursorAtStart, Action::Exit),\n                KeyRule::always(Action::CursorLeft),\n            ],\n        );\n\n        // Cursor at start → Exit\n        let ctx = make_ctx(0, 5, 0, 10);\n        assert_eq!(keymap.resolve(&key, &ctx), Some(Action::Exit));\n\n        // Cursor not at start → CursorLeft\n        let ctx = make_ctx(3, 5, 0, 10);\n        assert_eq!(keymap.resolve(&key, &ctx), Some(Action::CursorLeft));\n    }\n\n    #[test]\n    fn no_match_returns_none() {\n        let keymap = Keymap::new();\n        let key = KeyInput::parse(\"ctrl-c\").unwrap();\n        let ctx = make_ctx(0, 0, 0, 0);\n        assert_eq!(keymap.resolve(&key, &ctx), None);\n    }\n\n    #[test]\n    fn conditional_no_condition_matches_returns_none() {\n        let mut keymap = Keymap::new();\n        let key = KeyInput::parse(\"left\").unwrap();\n        // Only one rule with a condition that won't match\n        keymap.bind_conditional(\n            key.clone(),\n            vec![KeyRule::when(ConditionAtom::CursorAtStart, Action::Exit)],\n        );\n\n        // Cursor not at start → no match\n        let ctx = make_ctx(3, 5, 0, 10);\n        assert_eq!(keymap.resolve(&key, &ctx), None);\n    }\n\n    #[test]\n    fn has_sequence_starting_with() {\n        let mut keymap = Keymap::new();\n        let seq = KeyInput::parse(\"g g\").unwrap();\n        keymap.bind(seq, Action::ScrollToTop);\n\n        let g = SingleKey::parse(\"g\").unwrap();\n        assert!(keymap.has_sequence_starting_with(&g));\n\n        let h = SingleKey::parse(\"h\").unwrap();\n        assert!(!keymap.has_sequence_starting_with(&h));\n    }\n\n    #[test]\n    fn merge_overrides() {\n        let mut base = Keymap::new();\n        let key = KeyInput::parse(\"ctrl-c\").unwrap();\n        base.bind(key.clone(), Action::ReturnOriginal);\n\n        let mut overlay = Keymap::new();\n        overlay.bind(key.clone(), Action::Exit);\n\n        base.merge(&overlay);\n\n        let ctx = make_ctx(0, 0, 0, 0);\n        assert_eq!(base.resolve(&key, &ctx), Some(Action::Exit));\n    }\n\n    #[test]\n    fn merge_preserves_unoverridden() {\n        let mut base = Keymap::new();\n        let key1 = KeyInput::parse(\"ctrl-c\").unwrap();\n        let key2 = KeyInput::parse(\"ctrl-d\").unwrap();\n        base.bind(key1.clone(), Action::ReturnOriginal);\n        base.bind(key2.clone(), Action::DeleteCharAfter);\n\n        let mut overlay = Keymap::new();\n        overlay.bind(key1.clone(), Action::Exit);\n\n        base.merge(&overlay);\n\n        let ctx = make_ctx(0, 0, 0, 0);\n        assert_eq!(base.resolve(&key1, &ctx), Some(Action::Exit));\n        assert_eq!(base.resolve(&key2, &ctx), Some(Action::DeleteCharAfter));\n    }\n}\n"
  },
  {
    "path": "crates/atuin/src/command/client/search/keybindings/mod.rs",
    "content": "pub mod actions;\npub mod conditions;\npub mod defaults;\npub mod key;\npub mod keymap;\n\npub use actions::Action;\n#[allow(unused_imports)]\npub use conditions::{ConditionAtom, ConditionExpr, EvalContext};\npub use defaults::KeymapSet;\n#[allow(unused_imports)]\npub use key::{KeyCodeValue, KeyInput, SingleKey};\n#[allow(unused_imports)]\npub use keymap::{KeyBinding, KeyRule, Keymap};\n"
  },
  {
    "path": "crates/atuin/src/command/client/search.rs",
    "content": "use std::fs::File;\nuse std::io::{IsTerminal as _, Write, stderr, stdout};\n\nuse atuin_common::utils::{self, Escapable as _};\nuse clap::Parser;\nuse eyre::Result;\n\nuse atuin_client::{\n    database::Database,\n    database::{OptFilters, current_context},\n    encryption,\n    history::{History, store::HistoryStore},\n    record::sqlite_store::SqliteStore,\n    settings::{FilterMode, KeymapMode, SearchMode, Settings, Timezone},\n    theme::Theme,\n};\n\nuse super::history::ListMode;\n\nmod cursor;\nmod duration;\nmod engines;\nmod history_list;\nmod inspector;\nmod interactive;\npub mod keybindings;\n\npub use duration::format_duration_into;\n\n#[allow(clippy::struct_excessive_bools, clippy::struct_field_names)]\n#[derive(Parser, Debug)]\npub struct Cmd {\n    /// Filter search result by directory\n    #[arg(long, short)]\n    cwd: Option<String>,\n\n    /// Exclude directory from results\n    #[arg(long = \"exclude-cwd\")]\n    exclude_cwd: Option<String>,\n\n    /// Filter search result by exit code\n    #[arg(long, short)]\n    exit: Option<i64>,\n\n    /// Exclude results with this exit code\n    #[arg(long = \"exclude-exit\")]\n    exclude_exit: Option<i64>,\n\n    /// Only include results added before this date\n    #[arg(long, short)]\n    before: Option<String>,\n\n    /// Only include results after this date\n    #[arg(long)]\n    after: Option<String>,\n\n    /// How many entries to return at most\n    #[arg(long)]\n    limit: Option<i64>,\n\n    /// Offset from the start of the results\n    #[arg(long)]\n    offset: Option<i64>,\n\n    /// Open interactive search UI\n    #[arg(long, short)]\n    interactive: bool,\n\n    /// Allow overriding filter mode over config\n    #[arg(long = \"filter-mode\")]\n    filter_mode: Option<FilterMode>,\n\n    /// Allow overriding search mode over config\n    #[arg(long = \"search-mode\")]\n    search_mode: Option<SearchMode>,\n\n    /// Marker argument used to inform atuin that it was invoked from a shell up-key binding (hidden from help to avoid confusion)\n    #[arg(long = \"shell-up-key-binding\", hide = true)]\n    shell_up_key_binding: bool,\n\n    /// Notify the keymap at the shell's side\n    #[arg(long = \"keymap-mode\", default_value = \"auto\")]\n    keymap_mode: KeymapMode,\n\n    /// Use human-readable formatting for time\n    #[arg(long)]\n    human: bool,\n\n    #[arg(allow_hyphen_values = true)]\n    query: Option<Vec<String>>,\n\n    /// Show only the text of the command\n    #[arg(long)]\n    cmd_only: bool,\n\n    /// Terminate the output with a null, for better multiline handling\n    #[arg(long)]\n    print0: bool,\n\n    /// Delete anything matching this query. Will not print out the match\n    #[arg(long)]\n    delete: bool,\n\n    /// Delete EVERYTHING!\n    #[arg(long)]\n    delete_it_all: bool,\n\n    /// Reverse the order of results, oldest first\n    #[arg(long, short)]\n    reverse: bool,\n\n    /// Display the command time in another timezone other than the configured default.\n    ///\n    /// This option takes one of the following kinds of values:\n    /// - the special value \"local\" (or \"l\") which refers to the system time zone\n    /// - an offset from UTC (e.g. \"+9\", \"-2:30\")\n    #[arg(long, visible_alias = \"tz\")]\n    #[arg(allow_hyphen_values = true)]\n    // Clippy warns about `Option<Option<T>>`, but we suppress it because we need\n    // this distinction for proper argument handling.\n    #[allow(clippy::option_option)]\n    timezone: Option<Option<Timezone>>,\n\n    /// Available variables: {command}, {directory}, {duration}, {user}, {host}, {time}, {exit} and\n    /// {relativetime}.\n    /// Example: --format \"{time} - [{duration}] - {directory}$\\t{command}\"\n    #[arg(long, short)]\n    format: Option<String>,\n\n    /// Set the maximum number of lines Atuin's interface should take up.\n    #[arg(long = \"inline-height\")]\n    inline_height: Option<u16>,\n\n    /// Include duplicate commands in the output (non-interactive only)\n    #[arg(long)]\n    include_duplicates: bool,\n\n    /// File name to write the result to (hidden from help as this is meant to be used from a script)\n    #[arg(long = \"result-file\", hide = true)]\n    result_file: Option<String>,\n}\n\nimpl Cmd {\n    /// Returns true if this search command will run in interactive (TUI) mode\n    pub fn is_interactive(&self) -> bool {\n        self.interactive\n    }\n\n    // clippy: please write this instead\n    // clippy: now it has too many lines\n    // me: I'll do it later OKAY\n    #[allow(clippy::too_many_lines)]\n    pub async fn run(\n        self,\n        db: impl Database,\n        settings: &mut Settings,\n        store: SqliteStore,\n        theme: &Theme,\n    ) -> Result<()> {\n        let query = self.query.unwrap_or_else(|| {\n            std::env::var(\"ATUIN_QUERY\").map_or_else(\n                |_| vec![],\n                |query| {\n                    query\n                        .split(' ')\n                        .map(std::string::ToString::to_string)\n                        .collect()\n                },\n            )\n        });\n\n        if (self.delete_it_all || self.delete) && self.limit.is_some() {\n            // Because of how deletion is implemented, it will always delete all matches\n            // and disregard the limit option. It is also not clear what deletion with a\n            // limit would even mean. Deleting the LIMIT most recent entries that match\n            // the search query would make sense, but that wouldn't match what's displayed\n            // when running the equivalent search, but deleting those entries that are\n            // displayed with the search would leave any duplicates of those lines which may\n            // or may not have been intended to be deleted.\n            eprintln!(\"\\\"--limit\\\" is not compatible with deletion.\");\n            return Ok(());\n        }\n\n        if self.delete && query.is_empty() {\n            eprintln!(\n                \"Please specify a query to match the items you wish to delete. If you wish to delete all history, pass --delete-it-all\"\n            );\n            return Ok(());\n        }\n\n        if self.delete_it_all && !query.is_empty() {\n            eprintln!(\n                \"--delete-it-all will delete ALL of your history! It does not require a query.\"\n            );\n            return Ok(());\n        }\n\n        if let Some(search_mode) = self.search_mode {\n            settings.search_mode = search_mode;\n        }\n        if let Some(filter_mode) = self.filter_mode {\n            settings.filter_mode = Some(filter_mode);\n        }\n        if let Some(inline_height) = self.inline_height {\n            settings.inline_height = inline_height;\n        }\n\n        settings.shell_up_key_binding = self.shell_up_key_binding;\n\n        // `keymap_mode` specified in config.toml overrides the `--keymap-mode`\n        // option specified in the keybindings.\n        settings.keymap_mode = match settings.keymap_mode {\n            KeymapMode::Auto => self.keymap_mode,\n            value => value,\n        };\n        settings.keymap_mode_shell = self.keymap_mode;\n\n        let encryption_key: [u8; 32] = encryption::load_key(settings)?.into();\n\n        let host_id = Settings::host_id().await?;\n        let history_store = HistoryStore::new(store.clone(), host_id, encryption_key);\n\n        if self.interactive {\n            let item = interactive::history(&query, settings, db, &history_store, theme).await?;\n\n            if let Some(result_file) = self.result_file {\n                let mut file = File::create(result_file)?;\n                write!(file, \"{item}\")?;\n            } else if !stdout().is_terminal() {\n                // stdout is not a terminal - likely command substitution like VAR=$(atuin search -i)\n                // Write to stdout so it gets captured\n                println!(\"{item}\");\n            } else if stderr().is_terminal() {\n                eprintln!(\"{}\", item.escape_control());\n            } else {\n                eprintln!(\"{item}\");\n            }\n        } else {\n            let opt_filter = OptFilters {\n                exit: self.exit,\n                exclude_exit: self.exclude_exit,\n                cwd: self.cwd,\n                exclude_cwd: self.exclude_cwd,\n                before: self.before,\n                after: self.after,\n                limit: self.limit,\n                offset: self.offset,\n                reverse: self.reverse,\n                include_duplicates: self.include_duplicates,\n            };\n\n            let mut entries =\n                run_non_interactive(settings, opt_filter.clone(), &query, &db).await?;\n\n            if entries.is_empty() {\n                std::process::exit(1)\n            }\n\n            // if we aren't deleting, print it all\n            if self.delete || self.delete_it_all {\n                // delete it\n                // it only took me _years_ to add this\n                // sorry\n                while !entries.is_empty() {\n                    for entry in &entries {\n                        eprintln!(\"deleting {}\", entry.id);\n\n                        if settings.sync.records {\n                            let (id, _) = history_store.delete(entry.id.clone()).await?;\n                            history_store.incremental_build(&db, &[id]).await?;\n                        } else {\n                            db.delete(entry.clone()).await?;\n                        }\n                    }\n\n                    entries =\n                        run_non_interactive(settings, opt_filter.clone(), &query, &db).await?;\n                }\n            } else {\n                let format = match self.format {\n                    None => Some(settings.history_format.as_str()),\n                    _ => self.format.as_deref(),\n                };\n                let tz = match self.timezone {\n                    Some(Some(tz)) => tz,                   // User provided a value\n                    Some(None) | None => settings.timezone, // No value was provided\n                };\n\n                super::history::print_list(\n                    &entries,\n                    ListMode::from_flags(self.human, self.cmd_only),\n                    format,\n                    self.print0,\n                    true,\n                    tz,\n                );\n            }\n        }\n        Ok(())\n    }\n}\n\n// This is supposed to more-or-less mirror the command line version, so ofc\n// it is going to have a lot of args\n#[allow(clippy::too_many_arguments, clippy::cast_possible_truncation)]\nasync fn run_non_interactive(\n    settings: &Settings,\n    filter_options: OptFilters,\n    query: &[String],\n    db: &impl Database,\n) -> Result<Vec<History>> {\n    let dir = if filter_options.cwd.as_deref() == Some(\".\") {\n        Some(utils::get_current_dir())\n    } else {\n        filter_options.cwd\n    };\n\n    let context = current_context().await?;\n\n    let opt_filter = OptFilters {\n        cwd: dir.clone(),\n        ..filter_options\n    };\n\n    let filter_mode = settings.default_filter_mode(context.git_root.is_some());\n\n    let results = db\n        .search(\n            settings.search_mode,\n            filter_mode,\n            &context,\n            query.join(\" \").as_str(),\n            opt_filter,\n        )\n        .await?;\n\n    Ok(results)\n}\n\n#[cfg(test)]\nmod tests {\n    use super::Cmd;\n    use clap::Parser;\n\n    #[test]\n    fn search_for_triple_dash() {\n        // Issue #3028: searching for `---` should not be treated as a CLI flag\n        let cmd = Cmd::try_parse_from([\"search\", \"---\"]);\n        assert!(cmd.is_ok(), \"Failed to parse '---' as a query: {cmd:?}\");\n        let cmd = cmd.unwrap();\n        assert_eq!(cmd.query, Some(vec![\"---\".to_string()]));\n    }\n\n    #[test]\n    fn search_for_double_dash_value() {\n        // Searching for strings starting with -- should also work\n        let cmd = Cmd::try_parse_from([\"search\", \"--\", \"--foo\"]);\n        assert!(cmd.is_ok());\n        let cmd = cmd.unwrap();\n        assert_eq!(cmd.query, Some(vec![\"--foo\".to_string()]));\n    }\n}\n"
  },
  {
    "path": "crates/atuin/src/command/client/setup.rs",
    "content": "use atuin_client::settings::Settings;\n\nuse colored::Colorize;\nuse eyre::Result;\nuse std::io::{self, Write};\nuse toml_edit::{DocumentMut, value};\n\npub async fn run(_settings: &Settings) -> Result<()> {\n    let enable_ai = prompt(\n        \"Atuin AI\",\n        \"This will enable command generation and other AI features via the question mark key\",\n        Some(\n            \"By default, Atuin AI only has access to the name and version of your operating system and shell - your shell history is not sent to the AI.\",\n        ),\n    )?;\n\n    let enable_daemon = prompt(\n        \"Atuin Daemon\",\n        \"This will enable improved search and history sync using a persistent background process\",\n        None,\n    )?;\n\n    let config_file = Settings::get_config_path()?;\n    let config_str = tokio::fs::read_to_string(&config_file).await?;\n    let mut doc = config_str.parse::<DocumentMut>()?;\n\n    let mut changed = false;\n    if enable_ai {\n        changed = true;\n        if !doc.contains_key(\"ai\") {\n            doc[\"ai\"] = toml_edit::table();\n        }\n        doc[\"ai\"][\"enabled\"] = value(true);\n    }\n\n    if enable_daemon {\n        changed = true;\n        if !doc.contains_key(\"daemon\") {\n            doc[\"daemon\"] = toml_edit::table();\n        }\n        doc[\"daemon\"][\"enabled\"] = value(true);\n        doc[\"daemon\"][\"autostart\"] = value(true);\n        doc[\"search_mode\"] = value(\"daemon-fuzzy\");\n    }\n\n    if changed {\n        tokio::fs::write(config_file, doc.to_string()).await?;\n\n        println!(\n            \"{check} Settings updated successfully\",\n            check = \"✓\".bold().bright_green()\n        );\n    } else {\n        println!(\n            \"{check} No settings changed\",\n            check = \"✓\".bold().bright_green()\n        );\n    }\n\n    Ok(())\n}\n\npub fn prompt(feature: &str, description: &str, note: Option<&str>) -> Result<bool> {\n    println!(\n        \"> Enable {feature}?\",\n        feature = feature.bold().bright_blue()\n    );\n    if let Some(note) = note {\n        println!(\"  {description}\");\n        print!(\"  {note} {q} \", q = \"[Y/n]\".bold());\n    } else {\n        print!(\"  {description} {q} \", q = \"[Y/n]\".bold());\n    }\n\n    io::stdout().flush().ok();\n\n    let mut input = String::new();\n    io::stdin().read_line(&mut input)?;\n    let answer = input.trim().to_lowercase();\n    Ok(answer.is_empty() || answer == \"y\" || answer == \"yes\")\n}\n"
  },
  {
    "path": "crates/atuin/src/command/client/stats.rs",
    "content": "use clap::Parser;\nuse eyre::Result;\nuse interim::parse_date_string;\nuse time::{Duration, OffsetDateTime, Time};\n\nuse atuin_client::{\n    database::{Database, current_context},\n    settings::Settings,\n    theme::Theme,\n};\n\nuse atuin_history::stats::{compute, pretty_print};\n\nfn parse_ngram_size(s: &str) -> Result<usize, String> {\n    let value = s\n        .parse::<usize>()\n        .map_err(|_| format!(\"'{s}' is not a valid window size\"))?;\n\n    if value == 0 {\n        return Err(\"ngram window size must be at least 1\".to_string());\n    }\n\n    Ok(value)\n}\n\n#[derive(Parser, Debug)]\n#[command(infer_subcommands = true)]\npub struct Cmd {\n    /// Compute statistics for the specified period, leave blank for statistics since the beginning. See [this](https://docs.atuin.sh/reference/stats/) for more details.\n    period: Vec<String>,\n\n    /// How many top commands to list\n    #[arg(long, short, default_value = \"10\")]\n    count: usize,\n\n    /// The number of consecutive commands to consider\n    #[arg(long, short, default_value = \"1\", value_parser = parse_ngram_size)]\n    ngram_size: usize,\n}\n\nimpl Cmd {\n    pub async fn run(&self, db: &impl Database, settings: &Settings, theme: &Theme) -> Result<()> {\n        let context = current_context().await?;\n        let words = if self.period.is_empty() {\n            String::from(\"all\")\n        } else {\n            self.period.join(\" \")\n        };\n\n        let now = OffsetDateTime::now_utc().to_offset(settings.timezone.0);\n        let last_night = now.replace_time(Time::MIDNIGHT);\n\n        let history = if words.as_str() == \"all\" {\n            db.list(&[], &context, None, false, false).await?\n        } else if words.trim() == \"today\" {\n            let start = last_night;\n            let end = start + Duration::days(1);\n            db.range(start, end).await?\n        } else if words.trim() == \"month\" {\n            let end = last_night;\n            let start = end - Duration::days(31);\n            db.range(start, end).await?\n        } else if words.trim() == \"week\" {\n            let end = last_night;\n            let start = end - Duration::days(7);\n            db.range(start, end).await?\n        } else if words.trim() == \"year\" {\n            let end = last_night;\n            let start = end - Duration::days(365);\n            db.range(start, end).await?\n        } else {\n            let start = parse_date_string(&words, now, settings.dialect.into())?;\n            let end = start + Duration::days(1);\n            db.range(start, end).await?\n        };\n\n        let stats = compute(settings, &history, self.count, self.ngram_size);\n\n        if let Some(stats) = stats {\n            pretty_print(stats, self.ngram_size, theme);\n        }\n\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "crates/atuin/src/command/client/store/pull.rs",
    "content": "use clap::Args;\nuse eyre::Result;\n\nuse atuin_client::{\n    database::Database,\n    record::store::Store,\n    record::sync::Operation,\n    record::{sqlite_store::SqliteStore, sync},\n    settings::Settings,\n};\n\n#[derive(Args, Debug)]\npub struct Pull {\n    /// The tag to push (eg, 'history'). Defaults to all tags\n    #[arg(long, short)]\n    pub tag: Option<String>,\n\n    /// Force push records\n    /// This will first wipe the local store, and then download all records from the remote\n    #[arg(long, default_value = \"false\")]\n    pub force: bool,\n\n    /// Page Size\n    /// How many records to download at once. Defaults to 100\n    #[arg(long, default_value = \"100\")]\n    pub page: u64,\n}\n\nimpl Pull {\n    pub async fn run(\n        &self,\n        settings: &Settings,\n        store: SqliteStore,\n        db: &dyn Database,\n    ) -> Result<()> {\n        if self.force {\n            println!(\"Forcing local overwrite!\");\n            println!(\"Clearing local store\");\n\n            store.delete_all().await?;\n        }\n\n        // We can actually just use the existing diff/etc to push\n        // 1. Diff\n        // 2. Get operations\n        // 3. Filter operations by\n        //  a) are they a download op?\n        //  b) are they for the host/tag we are pushing here?\n        let (diff, _) = sync::diff(settings, &store).await?;\n        let operations = sync::operations(diff, &store).await?;\n\n        let operations = operations\n            .into_iter()\n            .filter(|op| match op {\n                // No noops or downloads thx\n                Operation::Noop { .. } | Operation::Upload { .. } => false,\n\n                // pull, so yes plz to downloads!\n                Operation::Download { tag, .. } => {\n                    if self.force {\n                        return true;\n                    }\n\n                    if let Some(t) = self.tag.clone()\n                        && t != *tag\n                    {\n                        return false;\n                    }\n\n                    true\n                }\n            })\n            .collect();\n\n        let (_, downloaded) = sync::sync_remote(operations, &store, settings, self.page).await?;\n\n        println!(\"Downloaded {} records\", downloaded.len());\n\n        crate::sync::build(settings, &store, db, Some(&downloaded)).await?;\n\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "crates/atuin/src/command/client/store/purge.rs",
    "content": "use clap::Args;\nuse eyre::Result;\n\nuse atuin_client::{\n    encryption::load_key,\n    record::{sqlite_store::SqliteStore, store::Store},\n    settings::Settings,\n};\n\n#[derive(Args, Debug)]\npub struct Purge {}\n\nimpl Purge {\n    pub async fn run(&self, settings: &Settings, store: SqliteStore) -> Result<()> {\n        println!(\"Purging local records that cannot be decrypted\");\n\n        let key = load_key(settings)?;\n\n        match store.purge(&key.into()).await {\n            Ok(()) => println!(\"Local store purge completed OK\"),\n            Err(e) => println!(\"Failed to purge local store: {e:?}\"),\n        }\n\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "crates/atuin/src/command/client/store/push.rs",
    "content": "use atuin_common::record::HostId;\nuse clap::Args;\nuse eyre::Result;\nuse uuid::Uuid;\n\nuse atuin_client::{\n    api_client::Client,\n    record::sync::Operation,\n    record::{sqlite_store::SqliteStore, sync},\n    settings::Settings,\n};\n\n#[derive(Args, Debug)]\npub struct Push {\n    /// The tag to push (eg, 'history'). Defaults to all tags\n    #[arg(long, short)]\n    pub tag: Option<String>,\n\n    /// The host to push, in the form of a UUID host ID. Defaults to the current host.\n    #[arg(long)]\n    pub host: Option<Uuid>,\n\n    /// Force push records\n    /// This will override both host and tag, to be all hosts and all tags. First clear the remote store, then upload all of the\n    /// local store\n    #[arg(long, default_value = \"false\")]\n    pub force: bool,\n\n    /// Page Size\n    /// How many records to upload at once. Defaults to 100\n    #[arg(long, default_value = \"100\")]\n    pub page: u64,\n}\n\nimpl Push {\n    pub async fn run(&self, settings: &Settings, store: SqliteStore) -> Result<()> {\n        let host_id = Settings::host_id().await?;\n\n        if self.force {\n            println!(\"Forcing remote store overwrite!\");\n            println!(\"Clearing remote store\");\n\n            let client = Client::new(\n                &settings.sync_address,\n                settings.sync_auth_token().await?,\n                settings.network_connect_timeout,\n                settings.network_timeout * 10, // we may be deleting a lot of data... so up the\n                                               // timeout\n            )\n            .expect(\"failed to create client\");\n\n            client.delete_store().await?;\n        }\n\n        // We can actually just use the existing diff/etc to push\n        // 1. Diff\n        // 2. Get operations\n        // 3. Filter operations by\n        //  a) are they an upload op?\n        //  b) are they for the host/tag we are pushing here?\n        let (diff, _) = sync::diff(settings, &store).await?;\n        let operations = sync::operations(diff, &store).await?;\n\n        let operations = operations\n            .into_iter()\n            .filter(|op| match op {\n                // No noops or downloads thx\n                Operation::Noop { .. } | Operation::Download { .. } => false,\n\n                // push, so yes plz to uploads!\n                Operation::Upload { host, tag, .. } => {\n                    if self.force {\n                        return true;\n                    }\n\n                    if let Some(h) = self.host {\n                        if HostId(h) != *host {\n                            return false;\n                        }\n                    } else if *host != host_id {\n                        return false;\n                    }\n\n                    if let Some(t) = self.tag.clone()\n                        && t != *tag\n                    {\n                        return false;\n                    }\n\n                    true\n                }\n            })\n            .collect();\n\n        let (uploaded, _) = sync::sync_remote(operations, &store, settings, self.page).await?;\n\n        println!(\"Uploaded {uploaded} records\");\n\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "crates/atuin/src/command/client/store/rebuild.rs",
    "content": "use atuin_dotfiles::store::{AliasStore, var::VarStore};\nuse atuin_scripts::store::ScriptStore;\nuse clap::Args;\nuse eyre::{Result, bail};\n\n#[cfg(feature = \"daemon\")]\nuse atuin_daemon::emit_event;\n\nuse atuin_client::{\n    database::Database, encryption, history::store::HistoryStore,\n    record::sqlite_store::SqliteStore, settings::Settings,\n};\n\n#[derive(Args, Debug)]\npub struct Rebuild {\n    pub tag: String,\n}\n\nimpl Rebuild {\n    pub async fn run(\n        &self,\n        settings: &Settings,\n        store: SqliteStore,\n        database: &dyn Database,\n    ) -> Result<()> {\n        // keep it as a string and not an enum atm\n        // would be super cool to build this dynamically in the future\n        // eg register handles for rebuilding various tags without having to make this part of the\n        // binary big\n        match self.tag.as_str() {\n            \"history\" => {\n                self.rebuild_history(settings, store.clone(), database)\n                    .await?;\n            }\n\n            \"dotfiles\" => {\n                self.rebuild_dotfiles(settings, store.clone()).await?;\n            }\n\n            \"scripts\" => {\n                self.rebuild_scripts(settings, store.clone()).await?;\n            }\n\n            tag => bail!(\"unknown tag: {tag}\"),\n        }\n\n        Ok(())\n    }\n\n    async fn rebuild_history(\n        &self,\n        settings: &Settings,\n        store: SqliteStore,\n        database: &dyn Database,\n    ) -> Result<()> {\n        let encryption_key: [u8; 32] = encryption::load_key(settings)?.into();\n\n        let host_id = Settings::host_id().await?;\n        let history_store = HistoryStore::new(store, host_id, encryption_key);\n\n        history_store.build(database).await?;\n\n        #[cfg(feature = \"daemon\")]\n        let _ = emit_event(atuin_daemon::DaemonEvent::HistoryRebuilt).await;\n\n        Ok(())\n    }\n\n    async fn rebuild_dotfiles(&self, settings: &Settings, store: SqliteStore) -> Result<()> {\n        let encryption_key: [u8; 32] = encryption::load_key(settings)?.into();\n\n        let host_id = Settings::host_id().await?;\n\n        let alias_store = AliasStore::new(store.clone(), host_id, encryption_key);\n        let var_store = VarStore::new(store.clone(), host_id, encryption_key);\n\n        alias_store.build().await?;\n        var_store.build().await?;\n\n        Ok(())\n    }\n\n    async fn rebuild_scripts(&self, settings: &Settings, store: SqliteStore) -> Result<()> {\n        let encryption_key: [u8; 32] = encryption::load_key(settings)?.into();\n        let host_id = Settings::host_id().await?;\n        let script_store = ScriptStore::new(store, host_id, encryption_key);\n        let database =\n            atuin_scripts::database::Database::new(settings.scripts.db_path.clone(), 1.0).await?;\n\n        script_store.build(database).await?;\n\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "crates/atuin/src/command/client/store/rekey.rs",
    "content": "use clap::Args;\nuse eyre::{Result, bail};\nuse tokio::{fs::File, io::AsyncWriteExt};\n\nuse atuin_client::{\n    encryption::{Key, decode_key, encode_key, generate_encoded_key, load_key},\n    record::sqlite_store::SqliteStore,\n    record::store::Store,\n    settings::Settings,\n};\n\n#[derive(Args, Debug)]\npub struct Rekey {\n    /// The new key to use for encryption. Omit for a randomly-generated key\n    key: Option<String>,\n}\n\nimpl Rekey {\n    pub async fn run(&self, settings: &Settings, store: SqliteStore) -> Result<()> {\n        let key = if let Some(key) = self.key.clone() {\n            println!(\"Re-encrypting store with specified key\");\n\n            match bip39::Mnemonic::from_phrase(&key, bip39::Language::English) {\n                Ok(mnemonic) => encode_key(Key::from_slice(mnemonic.entropy()))?,\n                Err(err) => {\n                    match err {\n                        // assume they copied in the base64 key\n                        bip39::ErrorKind::InvalidWord(_) => key,\n                        bip39::ErrorKind::InvalidChecksum => {\n                            bail!(\"key mnemonic was not valid\")\n                        }\n                        bip39::ErrorKind::InvalidKeysize(_)\n                        | bip39::ErrorKind::InvalidWordLength(_)\n                        | bip39::ErrorKind::InvalidEntropyLength(_, _) => {\n                            bail!(\"key was not the correct length\")\n                        }\n                    }\n                }\n            }\n        } else {\n            println!(\"Re-encrypting store with freshly-generated key\");\n            let (_, encoded) = generate_encoded_key()?;\n            encoded\n        };\n\n        let current_key: [u8; 32] = load_key(settings)?.into();\n        let new_key: [u8; 32] = decode_key(key.clone())?.into();\n\n        store.re_encrypt(&current_key, &new_key).await?;\n\n        println!(\"Store rewritten. Saving new key\");\n        let mut file = File::create(settings.key_path.clone()).await?;\n        file.write_all(key.as_bytes()).await?;\n\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "crates/atuin/src/command/client/store/verify.rs",
    "content": "use clap::Args;\nuse eyre::Result;\n\nuse atuin_client::{\n    encryption::load_key,\n    record::{sqlite_store::SqliteStore, store::Store},\n    settings::Settings,\n};\n\n#[derive(Args, Debug)]\npub struct Verify {}\n\nimpl Verify {\n    pub async fn run(&self, settings: &Settings, store: SqliteStore) -> Result<()> {\n        println!(\"Verifying local store can be decrypted with the current key\");\n\n        let key = load_key(settings)?;\n\n        match store.verify(&key.into()).await {\n            Ok(()) => println!(\"Local store encryption verified OK\"),\n            Err(e) => println!(\"Failed to verify local store encryption: {e:?}\"),\n        }\n\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "crates/atuin/src/command/client/store.rs",
    "content": "use clap::Subcommand;\nuse eyre::Result;\n\nuse atuin_client::{\n    database::Database,\n    record::{sqlite_store::SqliteStore, store::Store},\n    settings::Settings,\n};\nuse itertools::Itertools;\nuse time::{OffsetDateTime, UtcOffset};\n\n#[cfg(feature = \"sync\")]\nmod push;\n\n#[cfg(feature = \"sync\")]\nmod pull;\n\nmod purge;\nmod rebuild;\nmod rekey;\nmod verify;\n\n#[derive(Subcommand, Debug)]\n#[command(infer_subcommands = true)]\npub enum Cmd {\n    /// Print the current status of the record store\n    Status,\n\n    /// Rebuild a store (eg atuin store rebuild history)\n    Rebuild(rebuild::Rebuild),\n\n    /// Re-encrypt the store with a new key (potential for data loss!)\n    Rekey(rekey::Rekey),\n\n    /// Delete all records in the store that cannot be decrypted with the current key\n    Purge(purge::Purge),\n\n    /// Verify that all records in the store can be decrypted with the current key\n    Verify(verify::Verify),\n\n    /// Push all records to the remote sync server (one way sync)\n    #[cfg(feature = \"sync\")]\n    Push(push::Push),\n\n    /// Pull records from the remote sync server (one way sync)\n    #[cfg(feature = \"sync\")]\n    Pull(pull::Pull),\n}\n\nimpl Cmd {\n    pub async fn run(\n        &self,\n        settings: &Settings,\n        database: &dyn Database,\n        store: SqliteStore,\n    ) -> Result<()> {\n        match self {\n            Self::Status => self.status(store).await,\n            Self::Rebuild(rebuild) => rebuild.run(settings, store, database).await,\n            Self::Rekey(rekey) => rekey.run(settings, store).await,\n            Self::Verify(verify) => verify.run(settings, store).await,\n            Self::Purge(purge) => purge.run(settings, store).await,\n\n            #[cfg(feature = \"sync\")]\n            Self::Push(push) => push.run(settings, store).await,\n\n            #[cfg(feature = \"sync\")]\n            Self::Pull(pull) => pull.run(settings, store, database).await,\n        }\n    }\n\n    pub async fn status(&self, store: SqliteStore) -> Result<()> {\n        let host_id = Settings::host_id().await?;\n        let offset = UtcOffset::current_local_offset().unwrap_or(UtcOffset::UTC);\n\n        let status = store.status().await?;\n\n        // TODO: should probs build some data structure and then pretty-print it or smth\n        for (host, st) in status.hosts.iter().sorted_by_key(|(h, _)| *h) {\n            let host_string = if host == &host_id {\n                format!(\"host: {} <- CURRENT HOST\", host.0.as_hyphenated())\n            } else {\n                format!(\"host: {}\", host.0.as_hyphenated())\n            };\n\n            println!(\"{host_string}\");\n\n            for (tag, idx) in st.iter().sorted_by_key(|(tag, _)| *tag) {\n                println!(\"\\tstore: {tag}\");\n\n                let first = store.first(*host, tag).await?;\n                let last = store.last(*host, tag).await?;\n\n                println!(\"\\t\\tidx: {idx}\");\n\n                if let Some(first) = first {\n                    println!(\"\\t\\tfirst: {}\", first.id.0.as_hyphenated());\n\n                    let time =\n                        OffsetDateTime::from_unix_timestamp_nanos(i128::from(first.timestamp))?\n                            .to_offset(offset);\n                    println!(\"\\t\\t\\tcreated: {time}\");\n                }\n\n                if let Some(last) = last {\n                    println!(\"\\t\\tlast: {}\", last.id.0.as_hyphenated());\n\n                    let time =\n                        OffsetDateTime::from_unix_timestamp_nanos(i128::from(last.timestamp))?\n                            .to_offset(offset);\n                    println!(\"\\t\\t\\tcreated: {time}\");\n                }\n            }\n\n            println!();\n        }\n\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "crates/atuin/src/command/client/sync/status.rs",
    "content": "use crate::{SHA, VERSION};\nuse atuin_client::{api_client, database::Database, settings::Settings};\nuse colored::Colorize;\nuse eyre::{Result, bail};\n\npub async fn run(settings: &Settings, db: &impl Database) -> Result<()> {\n    if !settings.logged_in().await? {\n        bail!(\"You are not logged in to a sync server - cannot show sync status\");\n    }\n\n    let client = api_client::Client::new(\n        &settings.sync_address,\n        settings.sync_auth_token().await?,\n        settings.network_connect_timeout,\n        settings.network_timeout,\n    )?;\n\n    let me = client.me().await?;\n    let last_sync = Settings::last_sync().await?;\n\n    println!(\"Atuin v{VERSION} - Build rev {SHA}\\n\");\n\n    println!(\"{}\", \"[Local]\".green());\n\n    if settings.auto_sync {\n        println!(\"Sync frequency: {}\", settings.sync_frequency);\n        println!(\"Last sync: {}\", last_sync.to_offset(settings.timezone.0));\n    }\n\n    if !settings.sync.records {\n        let local_count = db.history_count(false).await?;\n        let deleted_count = db.history_count(true).await? - local_count;\n\n        println!(\"History count: {local_count}\");\n        println!(\"Deleted history count: {deleted_count}\\n\");\n    }\n\n    if settings.auto_sync {\n        println!(\"{}\", \"[Remote]\".green());\n        println!(\"Address: {}\", settings.sync_address);\n        println!(\"Username: {}\", me.username);\n    }\n\n    Ok(())\n}\n"
  },
  {
    "path": "crates/atuin/src/command/client/sync.rs",
    "content": "use clap::Subcommand;\nuse eyre::{Result, WrapErr};\n\nuse atuin_client::{\n    database::Database,\n    encryption,\n    history::store::HistoryStore,\n    record::{sqlite_store::SqliteStore, store::Store, sync},\n    settings::Settings,\n};\n\nmod status;\n\nuse crate::command::client::account;\n\n#[derive(Subcommand, Debug)]\n#[command(infer_subcommands = true)]\npub enum Cmd {\n    /// Sync with the configured server\n    Sync {\n        /// Force re-download everything\n        #[arg(long, short)]\n        force: bool,\n    },\n\n    /// Login to the configured server\n    Login(account::login::Cmd),\n\n    /// Log out\n    Logout,\n\n    /// Register with the configured server\n    Register(account::register::Cmd),\n\n    /// Print the encryption key for transfer to another machine\n    Key {\n        /// Switch to base64 output of the key\n        #[arg(long)]\n        base64: bool,\n    },\n\n    /// Display the sync status\n    Status,\n}\n\nimpl Cmd {\n    pub async fn run(\n        self,\n        settings: Settings,\n        db: &impl Database,\n        store: SqliteStore,\n    ) -> Result<()> {\n        match self {\n            Self::Sync { force } => run(&settings, force, db, store).await,\n            Self::Login(l) => l.run(&settings, &store).await,\n            Self::Logout => account::logout::run().await,\n            Self::Register(r) => r.run(&settings, &store).await,\n            Self::Status => status::run(&settings, db).await,\n            Self::Key { base64 } => {\n                use atuin_client::encryption::{encode_key, load_key};\n                let key = load_key(&settings).wrap_err(\"could not load encryption key\")?;\n\n                if base64 {\n                    let encode = encode_key(&key).wrap_err(\"could not encode encryption key\")?;\n                    println!(\"{encode}\");\n                } else {\n                    let mnemonic = bip39::Mnemonic::from_entropy(&key, bip39::Language::English)\n                        .map_err(|_| eyre::eyre!(\"invalid key\"))?;\n                    println!(\"{mnemonic}\");\n                }\n                Ok(())\n            }\n        }\n    }\n}\n\nasync fn run(\n    settings: &Settings,\n    force: bool,\n    db: &impl Database,\n    store: SqliteStore,\n) -> Result<()> {\n    if settings.sync.records {\n        let encryption_key: [u8; 32] = encryption::load_key(settings)\n            .context(\"could not load encryption key\")?\n            .into();\n\n        let host_id = Settings::host_id().await?;\n        let history_store = HistoryStore::new(store.clone(), host_id, encryption_key);\n\n        let (uploaded, downloaded) = sync::sync(settings, &store).await?;\n\n        crate::sync::build(settings, &store, db, Some(&downloaded)).await?;\n\n        println!(\"{uploaded}/{} up/down to record store\", downloaded.len());\n\n        let history_length = db.history_count(true).await?;\n        let store_history_length = store.len_tag(\"history\").await?;\n\n        #[allow(clippy::cast_sign_loss)]\n        if history_length as u64 > store_history_length {\n            println!(\n                \"{history_length} in history index, but {store_history_length} in history store\"\n            );\n            println!(\"Running automatic history store init...\");\n\n            // Internally we use the global filter mode, so this context is ignored.\n            // don't recurse or loop here.\n            history_store.init_store(db).await?;\n\n            println!(\"Re-running sync due to new records locally\");\n\n            // we'll want to run sync once more, as there will now be stuff to upload\n            let (uploaded, downloaded) = sync::sync(settings, &store).await?;\n\n            crate::sync::build(settings, &store, db, Some(&downloaded)).await?;\n\n            println!(\"{uploaded}/{} up/down to record store\", downloaded.len());\n        }\n    } else {\n        atuin_client::sync::sync(settings, force, db).await?;\n    }\n\n    println!(\n        \"Sync complete! {} items in history database, force: {}\",\n        db.history_count(true).await?,\n        force\n    );\n\n    Ok(())\n}\n"
  },
  {
    "path": "crates/atuin/src/command/client/wrapped.rs",
    "content": "use crossterm::style::{ResetColor, SetAttribute};\nuse eyre::Result;\nuse std::collections::{HashMap, HashSet};\nuse time::{Date, Duration, Month, OffsetDateTime, Time};\n\nuse atuin_client::{\n    database::Database, encryption, record::sqlite_store::SqliteStore, settings::Settings,\n    theme::Theme,\n};\nuse atuin_dotfiles::store::AliasStore;\n\nuse atuin_history::stats::{Stats, compute};\n\n#[derive(Debug)]\nstruct WrappedStats {\n    nav_commands: usize,\n    pkg_commands: usize,\n    error_rate: f64,\n    first_half_commands: Vec<(String, usize)>,\n    second_half_commands: Vec<(String, usize)>,\n    git_percentage: f64,\n    busiest_hour: Option<(String, usize)>,\n}\n\nimpl WrappedStats {\n    #[allow(clippy::too_many_lines, clippy::cast_precision_loss)]\n    fn new(\n        settings: &Settings,\n        stats: &Stats,\n        history: &[atuin_client::history::History],\n        alias_map: &HashMap<String, String>,\n    ) -> Self {\n        // Helper to expand alias to its first command word\n        let expand_alias = |cmd: &str| -> String {\n            alias_map.get(cmd).map_or_else(\n                || cmd.to_string(),\n                |expanded| {\n                    expanded\n                        .split_whitespace()\n                        .next()\n                        .unwrap_or(cmd)\n                        .to_string()\n                },\n            )\n        };\n\n        let nav_commands = stats\n            .top\n            .iter()\n            .filter(|(cmd, _)| {\n                let cmd = &cmd[0];\n                cmd == \"cd\" || cmd == \"ls\" || cmd == \"pwd\" || cmd == \"pushd\" || cmd == \"popd\"\n            })\n            .map(|(_, count)| count)\n            .sum();\n\n        let pkg_managers = [\n            \"cargo\",\n            \"npm\",\n            \"pnpm\",\n            \"yarn\",\n            \"pip\",\n            \"pip3\",\n            \"pipenv\",\n            \"poetry\",\n            \"pipx\",\n            \"uv\",\n            \"brew\",\n            \"apt\",\n            \"apt-get\",\n            \"apk\",\n            \"pacman\",\n            \"yay\",\n            \"paru\",\n            \"yum\",\n            \"dnf\",\n            \"dnf5\",\n            \"rpm\",\n            \"rpm-ostree\",\n            \"zypper\",\n            \"pkg\",\n            \"chocolatey\",\n            \"choco\",\n            \"scoop\",\n            \"winget\",\n            \"gem\",\n            \"bundle\",\n            \"shards\",\n            \"composer\",\n            \"gradle\",\n            \"maven\",\n            \"mvn\",\n            \"go get\",\n            \"nuget\",\n            \"dotnet\",\n            \"mix\",\n            \"hex\",\n            \"rebar3\",\n            \"nix\",\n            \"nix-env\",\n            \"cabal\",\n            \"opam\",\n        ];\n\n        let pkg_commands = history\n            .iter()\n            .filter(|h| {\n                let cmd = h.command.clone();\n                pkg_managers.iter().any(|pm| cmd.starts_with(pm))\n            })\n            .count();\n\n        // Error analysis\n        let mut command_errors: HashMap<String, (usize, usize)> = HashMap::new(); // (total_uses, errors)\n        let midyear = history[0].timestamp + Duration::days(182); // Split year in half\n\n        let mut first_half_commands: HashMap<String, usize> = HashMap::new();\n        let mut second_half_commands: HashMap<String, usize> = HashMap::new();\n        let mut hours: HashMap<String, usize> = HashMap::new();\n\n        for entry in history {\n            let raw_cmd = entry\n                .command\n                .split_whitespace()\n                .next()\n                .unwrap_or(\"\")\n                .to_string();\n            let cmd = expand_alias(&raw_cmd);\n            let (total, errors) = command_errors.entry(cmd.clone()).or_insert((0, 0));\n            *total += 1;\n            if entry.exit != 0 {\n                *errors += 1;\n            }\n\n            // Track command evolution\n            if entry.timestamp < midyear {\n                *first_half_commands.entry(cmd.clone()).or_default() += 1;\n            } else {\n                *second_half_commands.entry(cmd).or_default() += 1;\n            }\n\n            // Track hourly distribution\n            let local_time = entry\n                .timestamp\n                .to_offset(time::UtcOffset::current_local_offset().unwrap_or(settings.timezone.0));\n            let hour = format!(\"{:02}:00\", local_time.time().hour());\n            *hours.entry(hour).or_default() += 1;\n        }\n\n        let total_errors: usize = command_errors.values().map(|(_, errors)| errors).sum();\n        let total_commands: usize = command_errors.values().map(|(total, _)| total).sum();\n        let error_rate = total_errors as f64 / total_commands as f64;\n\n        // Process command evolution data\n        let mut first_half: Vec<_> = first_half_commands.into_iter().collect();\n        let mut second_half: Vec<_> = second_half_commands.into_iter().collect();\n        first_half.sort_by_key(|(_, count)| std::cmp::Reverse(*count));\n        second_half.sort_by_key(|(_, count)| std::cmp::Reverse(*count));\n        first_half.truncate(5);\n        second_half.truncate(5);\n\n        // Calculate git percentage\n        let git_commands: usize = stats\n            .top\n            .iter()\n            .filter(|(cmd, _)| cmd[0].starts_with(\"git\"))\n            .map(|(_, count)| count)\n            .sum();\n        let git_percentage = git_commands as f64 / stats.total_commands as f64;\n\n        // Find busiest hour\n        let busiest_hour = hours.into_iter().max_by_key(|(_, count)| *count);\n\n        Self {\n            nav_commands,\n            pkg_commands,\n            error_rate,\n            first_half_commands: first_half,\n            second_half_commands: second_half,\n            git_percentage,\n            busiest_hour,\n        }\n    }\n}\n\npub fn print_wrapped_header(year: i32) {\n    let reset = ResetColor;\n    let bold = SetAttribute(crossterm::style::Attribute::Bold);\n\n    println!(\"{bold}╭────────────────────────────────────╮{reset}\");\n    println!(\"{bold}│        ATUIN WRAPPED {year}          │{reset}\");\n    println!(\"{bold}│    Your Year in Shell History      │{reset}\");\n    println!(\"{bold}╰────────────────────────────────────╯{reset}\");\n    println!();\n}\n\n#[allow(clippy::cast_precision_loss)]\nfn print_fun_facts(wrapped_stats: &WrappedStats, stats: &Stats, year: i32) {\n    let reset = ResetColor;\n    let bold = SetAttribute(crossterm::style::Attribute::Bold);\n\n    if wrapped_stats.git_percentage > 0.05 {\n        println!(\n            \"{bold}🌟 You're a Git Power User!{reset} {bold}{:.1}%{reset} of your commands were Git operations\\n\",\n            wrapped_stats.git_percentage * 100.0\n        );\n    }\n    // Navigation patterns\n    let nav_percentage = wrapped_stats.nav_commands as f64 / stats.total_commands as f64 * 100.0;\n    if nav_percentage > 0.05 {\n        println!(\n            \"{bold}🚀 You're a Navigator!{reset} {bold}{nav_percentage:.1}%{reset} of your time was spent navigating directories\\n\",\n        );\n    }\n\n    // Command vocabulary\n    println!(\n        \"{bold}📚 Command Vocabulary{reset}: You know {bold}{}{reset} unique commands\\n\",\n        stats.unique_commands\n    );\n\n    // Package management\n    println!(\n        \"{bold}📦 Package Management{reset}: You ran {bold}{}{reset} package-related commands\\n\",\n        wrapped_stats.pkg_commands\n    );\n\n    // Error patterns\n    let error_percentage = wrapped_stats.error_rate * 100.0;\n    println!(\n        \"{bold}🚨 Error Analysis{reset}: Your commands failed {bold}{error_percentage:.1}%{reset} of the time\\n\",\n    );\n\n    // Command evolution\n    println!(\"🔍 Command Evolution:\");\n\n    // print stats for each half and compare\n    println!(\"  {bold}Top Commands{reset} in the first half of {year}:\");\n    for (cmd, count) in wrapped_stats.first_half_commands.iter().take(3) {\n        println!(\"    {bold}{cmd}{reset} ({count} times)\");\n    }\n\n    println!(\"  {bold}Top Commands{reset} in the second half of {year}:\");\n    for (cmd, count) in wrapped_stats.second_half_commands.iter().take(3) {\n        println!(\"    {bold}{cmd}{reset} ({count} times)\");\n    }\n\n    // Find new favorite commands (in top 5 of second half but not in first half)\n    let first_half_set: HashSet<_> = wrapped_stats\n        .first_half_commands\n        .iter()\n        .map(|(cmd, _)| cmd)\n        .collect();\n    let new_favorites: Vec<_> = wrapped_stats\n        .second_half_commands\n        .iter()\n        .filter(|(cmd, _)| !first_half_set.contains(cmd))\n        .take(2)\n        .collect();\n\n    if !new_favorites.is_empty() {\n        println!(\"  {bold}New favorites{reset} in the second half:\");\n        for (cmd, count) in new_favorites {\n            println!(\"    {bold}{cmd}{reset} ({count} times)\");\n        }\n    }\n\n    // Time patterns\n    if let Some((hour, count)) = &wrapped_stats.busiest_hour {\n        println!(\"\\n🕘 Most Productive Hour: {bold}{hour}{reset} ({count} commands)\",);\n\n        // Night owl or early bird\n        let hour_num = hour\n            .split(':')\n            .next()\n            .unwrap_or(\"0\")\n            .parse::<u32>()\n            .unwrap_or(0);\n        if hour_num >= 22 || hour_num <= 4 {\n            println!(\"  You're quite the night owl! 🦉\");\n        } else if (5..=7).contains(&hour_num) {\n            println!(\"  Early bird gets the worm! 🐦\");\n        }\n    }\n\n    println!();\n}\n\npub async fn run(\n    year: Option<i32>,\n    db: &impl Database,\n    settings: &Settings,\n    store: SqliteStore,\n    theme: &Theme,\n) -> Result<()> {\n    let now = OffsetDateTime::now_utc().to_offset(settings.timezone.0);\n    let month = now.month();\n\n    // If we're in December, then wrapped is for the current year. If not, it's for the previous year\n    let year = year.unwrap_or_else(|| {\n        if month == Month::December {\n            now.year()\n        } else {\n            now.year() - 1\n        }\n    });\n\n    let start = OffsetDateTime::new_in_offset(\n        Date::from_calendar_date(year, Month::January, 1).unwrap(),\n        Time::MIDNIGHT,\n        now.offset(),\n    );\n    let end = OffsetDateTime::new_in_offset(\n        Date::from_calendar_date(year, Month::December, 31).unwrap(),\n        Time::MIDNIGHT + Duration::days(1) - Duration::nanoseconds(1),\n        now.offset(),\n    );\n\n    let history = db.range(start, end).await?;\n    if history.is_empty() {\n        println!(\n            \"Your history for {year} is empty!\\nMaybe 'atuin import' could help you import your previous history 🪄\"\n        );\n        return Ok(());\n    }\n\n    // Load aliases for expansion\n    let alias_map: HashMap<String, String> = if settings.dotfiles.enabled {\n        if let Ok(encryption_key) = encryption::load_key(settings) {\n            let encryption_key: [u8; 32] = encryption_key.into();\n            let host_id = Settings::host_id().await?;\n            let alias_store = AliasStore::new(store, host_id, encryption_key);\n\n            alias_store\n                .aliases()\n                .await\n                .unwrap_or_default()\n                .into_iter()\n                .map(|a| (a.name, a.value))\n                .collect()\n        } else {\n            HashMap::new()\n        }\n    } else {\n        HashMap::new()\n    };\n\n    // Compute overall stats using existing functionality\n    let stats = compute(settings, &history, 10, 1).expect(\"Failed to compute stats\");\n    let wrapped_stats = WrappedStats::new(settings, &stats, &history, &alias_map);\n\n    // Print wrapped format\n    print_wrapped_header(year);\n\n    println!(\"🎉 In {year}, you typed {} commands!\", stats.total_commands);\n    println!(\n        \"   That's ~{} commands every day\\n\",\n        stats.total_commands / 365\n    );\n\n    println!(\"Your Top Commands:\");\n    atuin_history::stats::pretty_print(stats.clone(), 1, theme);\n    println!();\n\n    print_fun_facts(&wrapped_stats, &stats, year);\n\n    Ok(())\n}\n"
  },
  {
    "path": "crates/atuin/src/command/client.rs",
    "content": "use std::fs::{self, OpenOptions};\nuse std::path::{Path, PathBuf};\n\nuse clap::Subcommand;\nuse eyre::{Result, WrapErr};\n\nuse atuin_client::{\n    database::Sqlite, record::sqlite_store::SqliteStore, settings::Settings, theme,\n};\nuse tracing_appender::rolling::{RollingFileAppender, Rotation};\nuse tracing_subscriber::{\n    Layer, filter::EnvFilter, filter::LevelFilter, fmt, fmt::format::FmtSpan, prelude::*,\n};\n\nfn cleanup_old_logs(log_dir: &Path, prefix: &str, retention_days: u64) {\n    let cutoff = std::time::SystemTime::now()\n        - std::time::Duration::from_secs(retention_days * 24 * 60 * 60);\n\n    let Ok(entries) = fs::read_dir(log_dir) else {\n        return;\n    };\n\n    for entry in entries.flatten() {\n        let path = entry.path();\n        let Some(name) = path.file_name().and_then(|n| n.to_str()) else {\n            continue;\n        };\n\n        // Match files like \"search.log.2024-02-23\" or \"daemon.log.2024-02-23\"\n        if !name.starts_with(prefix) || name == prefix {\n            continue;\n        }\n\n        if let Ok(metadata) = entry.metadata()\n            && let Ok(modified) = metadata.modified()\n            && modified < cutoff\n        {\n            let _ = fs::remove_file(&path);\n        }\n    }\n}\n\n#[cfg(feature = \"sync\")]\nmod sync;\n\n#[cfg(feature = \"sync\")]\nmod account;\n\n#[cfg(feature = \"daemon\")]\nmod daemon;\n\nmod default_config;\nmod doctor;\nmod dotfiles;\nmod history;\nmod import;\nmod info;\nmod init;\nmod kv;\nmod scripts;\nmod search;\nmod setup;\nmod stats;\nmod store;\nmod wrapped;\n\n#[derive(Subcommand, Debug)]\n#[command(infer_subcommands = true)]\npub enum Cmd {\n    /// Setup Atuin features\n    #[command()]\n    Setup,\n\n    /// Manipulate shell history\n    #[command(subcommand)]\n    History(history::Cmd),\n\n    /// Import shell history from file\n    #[command(subcommand)]\n    Import(import::Cmd),\n\n    /// Calculate statistics for your history\n    Stats(stats::Cmd),\n\n    /// Interactive history search\n    Search(search::Cmd),\n\n    #[cfg(feature = \"sync\")]\n    #[command(flatten)]\n    Sync(sync::Cmd),\n\n    /// Manage your sync account\n    #[cfg(feature = \"sync\")]\n    Account(account::Cmd),\n\n    /// Get or set small key-value pairs\n    #[command(subcommand)]\n    Kv(kv::Cmd),\n\n    /// Manage the atuin data store\n    #[command(subcommand)]\n    Store(store::Cmd),\n\n    /// Manage your dotfiles with Atuin\n    #[command(subcommand)]\n    Dotfiles(dotfiles::Cmd),\n\n    /// Manage your scripts with Atuin\n    #[command(subcommand)]\n    Scripts(scripts::Cmd),\n\n    /// Print Atuin's shell init script\n    #[command()]\n    Init(init::Cmd),\n\n    /// Information about dotfiles locations and ENV vars\n    #[command()]\n    Info,\n\n    /// Run the doctor to check for common issues\n    #[command()]\n    Doctor,\n\n    #[command()]\n    Wrapped { year: Option<i32> },\n\n    /// *Experimental* Manage the background daemon\n    #[cfg(feature = \"daemon\")]\n    #[command()]\n    Daemon(daemon::Cmd),\n\n    /// Print the default atuin configuration (config.toml)\n    #[command()]\n    DefaultConfig,\n\n    /// Run the AI assistant\n    #[cfg(feature = \"ai\")]\n    #[command(subcommand)]\n    Ai(atuin_ai::commands::Commands),\n}\n\nimpl Cmd {\n    pub fn run(self) -> Result<()> {\n        // Daemonize before creating the async runtime – fork() inside a live\n        // tokio runtime corrupts its internal state.\n        #[cfg(all(unix, feature = \"daemon\"))]\n        if let Self::Daemon(ref cmd) = self\n            && cmd.should_daemonize()\n        {\n            daemon::daemonize_current_process()?;\n        }\n\n        let runtime = tokio::runtime::Builder::new_current_thread()\n            .enable_all()\n            .build()\n            .unwrap();\n\n        let settings = Settings::new().wrap_err(\"could not load client settings\")?;\n        let theme_manager = theme::ThemeManager::new(settings.theme.debug, None);\n        let res = runtime.block_on(self.run_inner(settings, theme_manager));\n\n        runtime.shutdown_timeout(std::time::Duration::from_millis(50));\n\n        res\n    }\n\n    #[allow(clippy::too_many_lines)]\n    async fn run_inner(\n        self,\n        mut settings: Settings,\n        mut theme_manager: theme::ThemeManager,\n    ) -> Result<()> {\n        // ATUIN_LOG env var overrides config file level settings\n        let env_log_set = std::env::var(\"ATUIN_LOG\").is_ok();\n\n        // Base filter from env var (or empty if not set)\n        let base_filter =\n            EnvFilter::from_env(\"ATUIN_LOG\").add_directive(\"sqlx_sqlite::regexp=off\".parse()?);\n\n        let is_interactive_search = matches!(&self, Self::Search(cmd) if cmd.is_interactive());\n        // Use file-based logging for interactive search (TUI mode)\n        let use_search_logging = is_interactive_search && settings.logs.search_enabled();\n\n        // Use file-based logging for daemon\n        #[cfg(feature = \"daemon\")]\n        let use_daemon_logging = matches!(&self, Self::Daemon(_)) && settings.logs.daemon_enabled();\n\n        #[cfg(not(feature = \"daemon\"))]\n        let use_daemon_logging = false;\n\n        // Check if daemon should also log to console\n        #[cfg(feature = \"daemon\")]\n        let daemon_show_logs = matches!(&self, Self::Daemon(cmd) if cmd.show_logs());\n\n        #[cfg(not(feature = \"daemon\"))]\n        let daemon_show_logs = false;\n\n        // Set up span timing JSON logs if ATUIN_SPAN is set\n        let span_path = std::env::var(\"ATUIN_SPAN\").ok().map(|p| {\n            if p.is_empty() {\n                \"atuin-spans.json\".to_string()\n            } else {\n                p\n            }\n        });\n\n        // Helper to create span timing layer\n        macro_rules! make_span_layer {\n            ($path:expr) => {{\n                let span_file = OpenOptions::new()\n                    .create(true)\n                    .truncate(true)\n                    .write(true)\n                    .open($path)?;\n                Some(\n                    fmt::layer()\n                        .json()\n                        .with_writer(span_file)\n                        .with_span_events(FmtSpan::NEW | FmtSpan::CLOSE)\n                        .with_filter(LevelFilter::TRACE),\n                )\n            }};\n        }\n\n        // Build the subscriber with all configured layers\n        if use_search_logging {\n            let search_filename = settings.logs.search.file.clone();\n            let log_dir = PathBuf::from(&settings.logs.dir);\n            fs::create_dir_all(&log_dir)?;\n\n            // Clean up old log files\n            cleanup_old_logs(&log_dir, &search_filename, settings.logs.search_retention());\n\n            let file_appender =\n                RollingFileAppender::new(Rotation::DAILY, &log_dir, &search_filename);\n\n            // Use config level unless ATUIN_LOG is set\n            let filter = if env_log_set {\n                base_filter\n            } else {\n                EnvFilter::default()\n                    .add_directive(settings.logs.search_level().as_directive().parse()?)\n                    .add_directive(\"sqlx_sqlite::regexp=off\".parse()?)\n            };\n\n            let base = tracing_subscriber::registry().with(\n                fmt::layer()\n                    .with_writer(file_appender)\n                    .with_ansi(false)\n                    .with_filter(filter),\n            );\n\n            match &span_path {\n                Some(sp) => {\n                    base.with(make_span_layer!(sp)).init();\n                }\n                None => {\n                    base.init();\n                }\n            }\n        } else if use_daemon_logging {\n            let daemon_filename = settings.logs.daemon.file.clone();\n            let log_dir = PathBuf::from(&settings.logs.dir);\n            fs::create_dir_all(&log_dir)?;\n\n            // Clean up old log files\n            cleanup_old_logs(&log_dir, &daemon_filename, settings.logs.daemon_retention());\n\n            let file_appender =\n                RollingFileAppender::new(Rotation::DAILY, &log_dir, &daemon_filename);\n\n            // Use config level unless ATUIN_LOG is set\n            let file_filter = if env_log_set {\n                base_filter\n            } else {\n                EnvFilter::default()\n                    .add_directive(settings.logs.daemon_level().as_directive().parse()?)\n                    .add_directive(\"sqlx_sqlite::regexp=off\".parse()?)\n            };\n\n            let file_layer = fmt::layer()\n                .with_writer(file_appender)\n                .with_ansi(false)\n                .with_filter(file_filter);\n\n            // Optionally add console layer for --show-logs\n            if daemon_show_logs {\n                let console_filter = EnvFilter::from_env(\"ATUIN_LOG\")\n                    .add_directive(\"sqlx_sqlite::regexp=off\".parse()?);\n\n                let console_layer = fmt::layer().with_filter(console_filter);\n\n                let base = tracing_subscriber::registry()\n                    .with(file_layer)\n                    .with(console_layer);\n\n                match &span_path {\n                    Some(sp) => {\n                        base.with(make_span_layer!(sp)).init();\n                    }\n                    None => {\n                        base.init();\n                    }\n                }\n            } else {\n                let base = tracing_subscriber::registry().with(file_layer);\n\n                match &span_path {\n                    Some(sp) => {\n                        base.with(make_span_layer!(sp)).init();\n                    }\n                    None => {\n                        base.init();\n                    }\n                }\n            }\n        }\n\n        tracing::trace!(command = ?self, \"client command\");\n\n        // Skip initializing any databases for history\n        // This is a pretty hot path, as it runs before and after every single command the user\n        // runs\n        match self {\n            Self::History(history) => return history.run(&settings).await,\n            Self::Init(init) => return init.run(&settings).await,\n            Self::Doctor => return doctor::run(&settings).await,\n            _ => {}\n        }\n\n        let db_path = PathBuf::from(settings.db_path.as_str());\n        let record_store_path = PathBuf::from(settings.record_store_path.as_str());\n\n        let db = Sqlite::new(db_path, settings.local_timeout).await?;\n        let sqlite_store = SqliteStore::new(record_store_path, settings.local_timeout).await?;\n\n        let theme_name = settings.theme.name.clone();\n        let theme = theme_manager.load_theme(theme_name.as_str(), settings.theme.max_depth);\n\n        match self {\n            Self::Setup => setup::run(&settings).await,\n            Self::Import(import) => import.run(&db).await,\n            Self::Stats(stats) => stats.run(&db, &settings, theme).await,\n            Self::Search(search) => search.run(db, &mut settings, sqlite_store, theme).await,\n\n            #[cfg(feature = \"sync\")]\n            Self::Sync(sync) => sync.run(settings, &db, sqlite_store).await,\n\n            #[cfg(feature = \"sync\")]\n            Self::Account(account) => account.run(settings, sqlite_store).await,\n\n            Self::Kv(kv) => kv.run(&settings, &sqlite_store).await,\n\n            Self::Store(store) => store.run(&settings, &db, sqlite_store).await,\n\n            Self::Dotfiles(dotfiles) => dotfiles.run(&settings, sqlite_store).await,\n\n            Self::Scripts(scripts) => scripts.run(&settings, sqlite_store, &db).await,\n\n            Self::Info => {\n                info::run(&settings);\n                Ok(())\n            }\n\n            Self::DefaultConfig => {\n                default_config::run();\n                Ok(())\n            }\n\n            Self::Wrapped { year } => wrapped::run(year, &db, &settings, sqlite_store, theme).await,\n\n            #[cfg(feature = \"daemon\")]\n            Self::Daemon(cmd) => cmd.run(settings, sqlite_store, db).await,\n\n            Self::History(_) | Self::Init(_) | Self::Doctor => unreachable!(),\n\n            #[cfg(feature = \"ai\")]\n            Self::Ai(cli) => atuin_ai::commands::run(cli, &settings).await,\n        }\n    }\n}\n"
  },
  {
    "path": "crates/atuin/src/command/contributors.rs",
    "content": "static CONTRIBUTORS: &str = include_str!(\"CONTRIBUTORS\");\n\npub fn run() {\n    println!(\"\\n{CONTRIBUTORS}\");\n}\n"
  },
  {
    "path": "crates/atuin/src/command/external.rs",
    "content": "use std::fmt::Write as _;\nuse std::process::Command;\nuse std::{io, process};\n\n#[cfg(feature = \"client\")]\nuse atuin_client::plugin::OfficialPluginRegistry;\nuse clap::CommandFactory;\nuse clap::builder::{StyledStr, Styles};\nuse eyre::Result;\n\nuse crate::Atuin;\n\npub fn run(args: &[String]) -> Result<()> {\n    let subcommand = &args[0];\n    let bin = format!(\"atuin-{subcommand}\");\n    let mut cmd = Command::new(&bin);\n    cmd.args(&args[1..]);\n\n    let spawn_result = match cmd.spawn() {\n        Ok(child) => Ok(child),\n        Err(e) => match e.kind() {\n            io::ErrorKind::NotFound => {\n                let output = render_not_found(subcommand, &bin);\n                Err(output)\n            }\n            _ => Err(e.to_string().into()),\n        },\n    };\n\n    match spawn_result {\n        Ok(mut child) => {\n            let status = child.wait()?;\n            if status.success() {\n                Ok(())\n            } else {\n                process::exit(status.code().unwrap_or(1));\n            }\n        }\n        Err(e) => {\n            eprintln!(\"{}\", e.ansi());\n            process::exit(1);\n        }\n    }\n}\n\nfn render_not_found(subcommand: &str, bin: &str) -> StyledStr {\n    let mut output = StyledStr::new();\n    let styles = Styles::styled();\n\n    let error = styles.get_error();\n    let invalid = styles.get_invalid();\n    let literal = styles.get_literal();\n\n    #[cfg(feature = \"client\")]\n    {\n        let registry = OfficialPluginRegistry::new();\n\n        // Check if this is an official plugin\n        if let Some(install_message) = registry.get_install_message(subcommand) {\n            let _ = write!(output, \"{error}error:{error:#} \");\n            let _ = write!(\n                output,\n                \"'{invalid}{subcommand}{invalid:#}' is an official atuin plugin, but it's not installed\"\n            );\n            let _ = write!(output, \"\\n\\n\");\n            let _ = write!(output, \"{install_message}\");\n            return output;\n        }\n    }\n\n    let mut atuin_cmd = Atuin::command();\n    let usage = atuin_cmd.render_usage();\n\n    let _ = write!(output, \"{error}error:{error:#} \");\n    let _ = write!(\n        output,\n        \"unrecognized subcommand '{invalid}{subcommand}{invalid:#}' \"\n    );\n    let _ = write!(\n        output,\n        \"and no executable named '{invalid}{bin}{invalid:#}' found in your PATH\"\n    );\n    let _ = write!(output, \"\\n\\n\");\n    let _ = write!(output, \"{usage}\");\n    let _ = write!(output, \"\\n\\n\");\n    let _ = write!(\n        output,\n        \"For more information, try '{literal}--help{literal:#}'.\"\n    );\n\n    output\n}\n"
  },
  {
    "path": "crates/atuin/src/command/gen_completions.rs",
    "content": "use clap::{CommandFactory, Parser, ValueEnum};\nuse clap_complete::{Generator, Shell, generate, generate_to};\nuse clap_complete_nushell::Nushell;\nuse eyre::Result;\n\n// clap put nushell completions into a separate package due to the maintainers\n// being a little less committed to support them.\n// This means we have to do a tiny bit of legwork to combine these completions\n// into one command.\n#[derive(Debug, Clone, ValueEnum)]\n#[value(rename_all = \"lower\")]\npub enum GenShell {\n    Bash,\n    Elvish,\n    Fish,\n    Nushell,\n    PowerShell,\n    Zsh,\n}\n\nimpl Generator for GenShell {\n    fn file_name(&self, name: &str) -> String {\n        match self {\n            // clap_complete\n            Self::Bash => Shell::Bash.file_name(name),\n            Self::Elvish => Shell::Elvish.file_name(name),\n            Self::Fish => Shell::Fish.file_name(name),\n            Self::PowerShell => Shell::PowerShell.file_name(name),\n            Self::Zsh => Shell::Zsh.file_name(name),\n\n            // clap_complete_nushell\n            Self::Nushell => Nushell.file_name(name),\n        }\n    }\n\n    fn generate(&self, cmd: &clap::Command, buf: &mut dyn std::io::prelude::Write) {\n        match self {\n            // clap_complete\n            Self::Bash => Shell::Bash.generate(cmd, buf),\n            Self::Elvish => Shell::Elvish.generate(cmd, buf),\n            Self::Fish => Shell::Fish.generate(cmd, buf),\n            Self::PowerShell => Shell::PowerShell.generate(cmd, buf),\n            Self::Zsh => Shell::Zsh.generate(cmd, buf),\n\n            // clap_complete_nushell\n            Self::Nushell => Nushell.generate(cmd, buf),\n        }\n    }\n}\n\n#[derive(Debug, Parser)]\npub struct Cmd {\n    /// Set the shell for generating completions\n    #[arg(long, short)]\n    shell: GenShell,\n\n    /// Set the output directory\n    #[arg(long, short)]\n    out_dir: Option<String>,\n}\n\nimpl Cmd {\n    pub fn run(self) -> Result<()> {\n        let Cmd { shell, out_dir } = self;\n\n        let mut cli = crate::Atuin::command();\n\n        match out_dir {\n            Some(out_dir) => {\n                generate_to(shell, &mut cli, env!(\"CARGO_PKG_NAME\"), &out_dir)?;\n            }\n            None => {\n                generate(\n                    shell,\n                    &mut cli,\n                    env!(\"CARGO_PKG_NAME\"),\n                    &mut std::io::stdout(),\n                );\n            }\n        }\n\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "crates/atuin/src/command/mod.rs",
    "content": "use clap::Subcommand;\nuse eyre::Result;\n\n#[cfg(not(windows))]\nuse rustix::{fs::Mode, process::umask};\n\n#[cfg(feature = \"client\")]\nmod client;\n\nmod contributors;\n\nmod gen_completions;\n\nmod external;\n\n#[derive(Subcommand)]\n#[command(infer_subcommands = true)]\n#[allow(clippy::large_enum_variant)]\npub enum AtuinCmd {\n    #[cfg(feature = \"client\")]\n    #[command(flatten)]\n    Client(client::Cmd),\n\n    /// Terminal emulator for atuin\n    #[cfg(feature = \"hex\")]\n    Hex {\n        #[command(subcommand)]\n        cmd: Option<atuin_hex::Cmd>,\n    },\n\n    /// Generate a UUID\n    Uuid,\n\n    Contributors,\n\n    /// Generate shell completions\n    GenCompletions(gen_completions::Cmd),\n\n    #[command(external_subcommand)]\n    External(Vec<String>),\n}\n\nimpl AtuinCmd {\n    pub fn run(self) -> Result<()> {\n        #[cfg(not(windows))]\n        {\n            // set umask before we potentially open/create files\n            // or in other words, 077. Do not allow any access to any other user\n            let mode = Mode::RWXG | Mode::RWXO;\n            umask(mode);\n        }\n\n        match self {\n            #[cfg(feature = \"client\")]\n            Self::Client(client) => client.run(),\n\n            #[cfg(feature = \"hex\")]\n            Self::Hex { cmd } => {\n                atuin_hex::run(cmd);\n                Ok(())\n            }\n\n            Self::Contributors => {\n                contributors::run();\n                Ok(())\n            }\n            Self::Uuid => {\n                println!(\"{}\", atuin_common::utils::uuid_v7().as_simple());\n                Ok(())\n            }\n            Self::GenCompletions(gen_completions) => gen_completions.run(),\n            Self::External(args) => external::run(&args),\n        }\n    }\n}\n"
  },
  {
    "path": "crates/atuin/src/main.rs",
    "content": "#![warn(clippy::pedantic, clippy::nursery)]\n#![allow(clippy::use_self, clippy::missing_const_for_fn)] // not 100% reliable\n\nuse clap::Parser;\nuse clap::builder::Styles;\nuse clap::builder::styling::{AnsiColor, Effects};\nuse eyre::Result;\n\nuse command::AtuinCmd;\n\nmod command;\n\n#[cfg(feature = \"sync\")]\nmod sync;\n\nconst VERSION: &str = env!(\"CARGO_PKG_VERSION\");\nconst SHA: &str = env!(\"GIT_HASH\");\n\nconst LONG_VERSION: &str = concat!(env!(\"CARGO_PKG_VERSION\"), \" (\", env!(\"GIT_HASH\"), \")\");\n\nstatic HELP_TEMPLATE: &str = \"\\\n{before-help}{name} {version}\n{author}\n{about}\n\n{usage-heading}\n  {usage}\n\n{all-args}{after-help}\";\n\nconst STYLES: Styles = Styles::styled()\n    .header(AnsiColor::Yellow.on_default().effects(Effects::BOLD))\n    .usage(AnsiColor::Green.on_default().effects(Effects::BOLD))\n    .literal(AnsiColor::Green.on_default().effects(Effects::BOLD))\n    .placeholder(AnsiColor::Green.on_default());\n\n/// Magical shell history\n#[derive(Parser)]\n#[command(\n    author = \"Ellie Huxtable <ellie@atuin.sh>\",\n    version = VERSION,\n    long_version = LONG_VERSION,\n    help_template(HELP_TEMPLATE),\n    styles = STYLES,\n)]\nstruct Atuin {\n    #[command(subcommand)]\n    atuin: AtuinCmd,\n}\n\nimpl Atuin {\n    fn run(self) -> Result<()> {\n        self.atuin.run()\n    }\n}\n\nfn main() -> Result<()> {\n    Atuin::parse().run()\n}\n"
  },
  {
    "path": "crates/atuin/src/shell/.gitattributes",
    "content": "* eol=lf\n"
  },
  {
    "path": "crates/atuin/src/shell/atuin.bash",
    "content": "# Include guard\nif [[ ${__atuin_initialized-} == true ]]; then\n    false\nelif [[ $- != *i* ]]; then\n    # Enable only in interactive shells\n    false\nelif ((BASH_VERSINFO[0] < 3 || BASH_VERSINFO[0] == 3 && BASH_VERSINFO[1] < 1)); then\n    # Require bash >= 3.1\n    [[ -t 2 ]] && printf 'atuin: requires bash >= 3.1 for the integration.\\n' >&2\n    false\nelse # (include guard) beginning of main content\n#------------------------------------------------------------------------------\n__atuin_initialized=true\n\nif [[ -z \"${ATUIN_SESSION:-}\" || \"${ATUIN_SHLVL:-}\" != \"$SHLVL\" ]]; then\n    ATUIN_SESSION=$(atuin uuid)\n    export ATUIN_SESSION\n    export ATUIN_SHLVL=$SHLVL\nfi\nATUIN_STTY=$(stty -g)\nATUIN_HISTORY_ID=\"\"\n\nexport ATUIN_PREEXEC_BACKEND=$SHLVL:none\n__atuin_update_preexec_backend() {\n    if [[ ${BLE_ATTACHED-} ]]; then\n        ATUIN_PREEXEC_BACKEND=$SHLVL:blesh-${BLE_VERSION-}\n    elif [[ ${bash_preexec_imported-} ]]; then\n        ATUIN_PREEXEC_BACKEND=$SHLVL:bash-preexec\n    elif [[ ${__bp_imported-} ]]; then\n        ATUIN_PREEXEC_BACKEND=\"$SHLVL:bash-preexec (old)\"\n    else\n        ATUIN_PREEXEC_BACKEND=$SHLVL:unknown\n    fi\n}\n\n__atuin_preexec() {\n    # Workaround for old versions of bash-preexec\n    if [[ ! ${BLE_ATTACHED-} ]]; then\n        # In older versions of bash-preexec, the preexec hook may be called\n        # even for the commands run by keybindings.  There is no general and\n        # robust way to detect the command for keybindings, but at least we\n        # want to exclude Atuin's keybindings.  When the preexec hook is called\n        # for a keybinding, the preexec hook for the user command will not\n        # fire, so we instead set a fake ATUIN_HISTORY_ID here to notify\n        # __atuin_precmd of this failure.\n        if [[ $BASH_COMMAND != \"$1\" ]]; then\n            case $BASH_COMMAND in\n                '__atuin_history'* | '__atuin_widget_run'* | '__atuin_bash42_dispatch'*)\n                    ATUIN_HISTORY_ID=__bash_preexec_failure__\n                    return 0 ;;\n            esac\n        fi\n    fi\n\n    # Note: We update ATUIN_PREEXEC_BACKEND on every preexec because blesh's\n    # attaching state can dynamically change.\n    __atuin_update_preexec_backend\n\n    local id\n    id=$(atuin history start -- \"$1\" 2>/dev/null)\n    export ATUIN_HISTORY_ID=$id\n    __atuin_preexec_time=${EPOCHREALTIME-}\n}\n\n__atuin_precmd() {\n    local EXIT=$? __atuin_precmd_time=${EPOCHREALTIME-}\n\n    [[ ! $ATUIN_HISTORY_ID ]] && return\n\n    # If the previous preexec hook failed, we manually call __atuin_preexec\n    if [[ $ATUIN_HISTORY_ID == __bash_preexec_failure__ ]]; then\n        # This is the command extraction code taken from bash-preexec\n        local previous_command\n        previous_command=$(\n            export LC_ALL=C HISTTIMEFORMAT=''\n            builtin history 1 | sed '1 s/^ *[0-9][0-9]*[* ] //'\n        )\n        __atuin_preexec \"$previous_command\"\n    fi\n\n    local duration=\"\"\n    # shellcheck disable=SC2154,SC2309\n    if [[ ${BLE_ATTACHED-} && ${_ble_exec_time_ata-} ]]; then\n        # With ble.sh, we utilize the shell variable `_ble_exec_time_ata`\n        # recorded by ble.sh.  It is more accurate than the measurements by\n        # Atuin, which includes the spawn cost of Atuin.  ble.sh uses the\n        # special shell variable `EPOCHREALTIME` in bash >= 5.0 with the\n        # microsecond resolution, or the builtin `time` in bash < 5.0 with the\n        # millisecond resolution.\n        duration=${_ble_exec_time_ata}000\n    elif ((BASH_VERSINFO[0] >= 5)); then\n        # We calculate the high-resolution duration based on EPOCHREALTIME\n        # (bash >= 5.0) recorded by precmd/preexec, though it might not be as\n        # accurate as `_ble_exec_time_ata` provided by ble.sh because it\n        # includes the extra time of the precmd/preexec handling.  Since Bash\n        # does not offer floating-point arithmetic, we remove the non-digit\n        # characters and perform the integral arithmetic.  The fraction part of\n        # EPOCHREALTIME is fixed to have 6 digits in Bash.  We remove all the\n        # non-digit characters because the decimal point is not necessarily a\n        # period depending on the locale.\n        duration=$((${__atuin_precmd_time//[!0-9]} - ${__atuin_preexec_time//[!0-9]}))\n        if ((duration >= 0)); then\n            duration=${duration}000\n        else\n            duration=\"\" # clear the result on overflow\n        fi\n    fi\n\n    (ATUIN_LOG=error atuin history end --exit \"$EXIT\" ${duration:+\"--duration=$duration\"} -- \"$ATUIN_HISTORY_ID\" &) >/dev/null 2>&1\n    export ATUIN_HISTORY_ID=\"\"\n}\n\n__atuin_set_ret_value() {\n    return ${1:+\"$1\"}\n}\n\n#------------------------------------------------------------------------------\n# section: __atuin_accept_line\n#\n# The function \"__atuin_accept_line\" is kept for backward compatibility of the\n# direct use of __atuin_history in keybindings by users.\n\n# The shell function `__atuin_evaluate_prompt` evaluates prompt sequences in\n# $PS1.  We switch the implementation of the shell function\n# `__atuin_evaluate_prompt` based on the Bash version because the expansion\n# ${PS1@P} is only available in bash >= 4.4.\nif ((BASH_VERSINFO[0] >= 5 || BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] >= 4)); then\n    __atuin_evaluate_prompt() {\n        __atuin_set_ret_value \"${__bp_last_ret_value-}\" \"${__bp_last_argument_prev_command-}\"\n        __atuin_prompt=${PS1@P}\n    \n        # Note: Strip the control characters ^A (\\001) and ^B (\\002), which\n        # Bash internally uses to enclose the escape sequences.  They are\n        # produced by '\\[' and '\\]', respectively, in $PS1 and used to tell\n        # Bash that the strings inbetween do not contribute to the prompt\n        # width.  After the prompt width calculation, Bash strips those control\n        # characters before outputting it to the terminal.  We here strip these\n        # characters following Bash's behavior.\n        __atuin_prompt=${__atuin_prompt//[$'\\001\\002']}\n\n        # Count the number of newlines contained in $__atuin_prompt\n        __atuin_prompt_offset=${__atuin_prompt//[!$'\\n']}\n        __atuin_prompt_offset=${#__atuin_prompt_offset}\n    }\nelse\n    __atuin_evaluate_prompt() {\n        __atuin_prompt='$ '\n        __atuin_prompt_offset=0\n    }\nfi\n\n# The shell function `__atuin_clear_prompt N` outputs terminal control\n# sequences to clear the contents of the current and N previous lines.  After\n# clearing, the cursor is placed at the beginning of the N-th previous line.\n__atuin_clear_prompt_cache=()\n__atuin_clear_prompt() {\n    local offset=$1\n    if [[ ! ${__atuin_clear_prompt_cache[offset]+set} ]]; then\n        if [[ ! ${__atuin_clear_prompt_cache[0]+set} ]]; then\n            __atuin_clear_prompt_cache[0]=$'\\r'$(tput el 2>/dev/null || tput ce 2>/dev/null)\n        fi\n        if ((offset > 0)); then\n            __atuin_clear_prompt_cache[offset]=${__atuin_clear_prompt_cache[0]}$(\n                tput cuu \"$offset\" 2>/dev/null || tput UP \"$offset\" 2>/dev/null\n                tput dl \"$offset\"  2>/dev/null || tput DL \"$offset\" 2>/dev/null\n                tput il \"$offset\"  2>/dev/null || tput AL \"$offset\" 2>/dev/null\n            )\n        fi\n    fi\n    printf '%s' \"${__atuin_clear_prompt_cache[offset]}\"\n}\n\n__atuin_accept_line() {\n    local __atuin_command=$1\n\n    # Reprint the prompt, accounting for multiple lines\n    local __atuin_prompt __atuin_prompt_offset\n    __atuin_evaluate_prompt\n    __atuin_clear_prompt \"$__atuin_prompt_offset\"\n    printf '%s\\n' \"$__atuin_prompt$__atuin_command\"\n\n    # Add it to the bash history\n    history -s \"$__atuin_command\"\n\n    # Assuming bash-preexec\n    # Invoke every function in the preexec array\n    local __atuin_preexec_function\n    local __atuin_preexec_function_ret_value\n    local __atuin_preexec_ret_value=0\n    for __atuin_preexec_function in \"${preexec_functions[@]:-}\"; do\n        if type -t \"$__atuin_preexec_function\" 1>/dev/null; then\n            __atuin_set_ret_value \"${__bp_last_ret_value:-}\"\n            \"$__atuin_preexec_function\" \"$__atuin_command\"\n            __atuin_preexec_function_ret_value=$?\n            if [[ $__atuin_preexec_function_ret_value != 0 ]]; then\n                __atuin_preexec_ret_value=$__atuin_preexec_function_ret_value\n            fi\n        fi\n    done\n\n    # If extdebug is turned on and any preexec function returns non-zero\n    # exit status, we do not run the user command.\n    if ! { shopt -q extdebug && ((__atuin_preexec_ret_value)); }; then\n        # Note: When a child Bash session is started by enter_accept, if the\n        # environment variable READLINE_POINT is present, bash-preexec in the\n        # child session does not fire preexec at all because it considers we\n        # are inside Atuin's keybinding of the current session.  To avoid\n        # propagating the environment variable to the child session, we remove\n        # the export attribute of READLINE_LINE and READLINE_POINT.\n        export -n READLINE_LINE READLINE_POINT\n\n        # Juggle the terminal settings so that the command can be interacted\n        # with\n        local __atuin_stty_backup\n        __atuin_stty_backup=$(stty -g)\n        stty \"$ATUIN_STTY\"\n\n        # Execute the command.  Note: We need to record $? and $_ after the\n        # user command within the same call of \"eval\" because $_ is otherwise\n        # overwritten by the last argument of \"eval\".\n        __atuin_set_ret_value \"${__bp_last_ret_value-}\" \"${__bp_last_argument_prev_command-}\"\n        eval -- \"$__atuin_command\"$'\\n__bp_last_ret_value=$? __bp_last_argument_prev_command=$_'\n\n        stty \"$__atuin_stty_backup\"\n    fi\n\n    # Execute preprompt commands\n    local __atuin_prompt_command\n    for __atuin_prompt_command in \"${PROMPT_COMMAND[@]}\"; do\n        __atuin_set_ret_value \"${__bp_last_ret_value-}\" \"${__bp_last_argument_prev_command-}\"\n        eval -- \"$__atuin_prompt_command\"\n    done\n    # Bash will redraw only the line with the prompt after we finish,\n    # so to work for a multiline prompt we need to print it ourselves,\n    # then go to the beginning of the last line.\n    __atuin_evaluate_prompt\n    printf '%s' \"$__atuin_prompt\"\n    __atuin_clear_prompt 0\n}\n\n#------------------------------------------------------------------------------\n\n# Check if tmux popup is available (tmux >= 3.2)\n__atuin_tmux_popup_check() {\n    [[ -n \"${TMUX-}\" ]] || return 1\n    [[ \"${ATUIN_TMUX_POPUP:-true}\" != \"false\" ]] || return 1\n\n    # https://github.com/tmux/tmux/wiki/FAQ#how-often-is-tmux-released-what-is-the-version-number-scheme\n    local tmux_version\n    tmux_version=$(tmux -V 2>/dev/null | sed -n 's/^[^0-9]*\\([0-9][0-9]*\\.[0-9][0-9]*\\).*/\\1/p') # Could have used grep...\n    [[ -z \"$tmux_version\" ]] && return 1\n\n    local m1 m2\n    m1=${tmux_version%%.*}\n    m2=${tmux_version#*.}\n    m2=${m2%%.*}\n    [[ \"$m1\" =~ ^[0-9]+$ ]] || return 1\n    [[ \"$m2\" =~ ^[0-9]+$ ]] || m2=0\n    (( m1 > 3 || (m1 == 3 && m2 >= 2) ))\n}\n\n# Use global variable to fix scope issues with traps\n__atuin_popup_tmpdir=\"\"\n__atuin_tmux_popup_cleanup() {\n    [[ -n \"$__atuin_popup_tmpdir\" && -d \"$__atuin_popup_tmpdir\" ]] && command rm -rf \"$__atuin_popup_tmpdir\"\n    __atuin_popup_tmpdir=\"\"\n}\n\n__atuin_search_cmd() {\n    local -a search_args=(\"$@\")\n\n    if __atuin_tmux_popup_check; then\n        __atuin_popup_tmpdir=$(mktemp -d) || return 1\n        local result_file=\"$__atuin_popup_tmpdir/result\"\n\n        trap '__atuin_tmux_popup_cleanup' EXIT HUP INT TERM\n\n        local escaped_query escaped_args\n        escaped_query=$(printf '%s' \"$READLINE_LINE\" | sed \"s/'/'\\\\\\\\''/g\")\n        escaped_args=\"\"\n        for arg in \"${search_args[@]}\"; do\n            escaped_args+=\" '$(printf '%s' \"$arg\" | sed \"s/'/'\\\\\\\\''/g\")'\"\n        done\n\n        # In the popup, atuin goes to terminal, stderr goes to file\n        local cdir popup_width popup_height\n        cdir=$(pwd)\n        popup_width=\"${ATUIN_TMUX_POPUP_WIDTH:-80%}\" # Keep default value anyways\n        popup_height=\"${ATUIN_TMUX_POPUP_HEIGHT:-60%}\"\n        tmux display-popup -d \"$cdir\" -w \"$popup_width\" -h \"$popup_height\" -E -E -- \\\n            sh -c \"PATH='$PATH' ATUIN_SESSION='$ATUIN_SESSION' ATUIN_SHELL=bash ATUIN_LOG=error ATUIN_QUERY='$escaped_query' atuin search $escaped_args -i 2>'$result_file'\"\n\n        if [[ -f \"$result_file\" ]]; then\n            cat \"$result_file\"\n        fi\n\n        __atuin_tmux_popup_cleanup\n        trap - EXIT HUP INT TERM\n    else\n        ATUIN_SHELL=bash ATUIN_LOG=error ATUIN_QUERY=$READLINE_LINE atuin search \"${search_args[@]}\" -i 3>&1 1>&2 2>&3\n    fi\n}\n\n__atuin_history() {\n    # Default action of the up key: When this function is called with the first\n    # argument `--shell-up-key-binding`, we perform Atuin's history search only\n    # when the up key is supposed to cause the history movement in the original\n    # binding.  We do this only for ble.sh because the up key always invokes\n    # the history movement in the plain Bash.\n    if [[ ${BLE_ATTACHED-} && ${1-} == --shell-up-key-binding ]]; then\n        # When the current cursor position is not in the first line, the up key\n        # should move the cursor to the previous line.  While the selection is\n        # performed, the up key should not start the history search.\n        # shellcheck disable=SC2154 # Note: these variables are set by ble.sh\n        if [[ ${_ble_edit_str::_ble_edit_ind} == *$'\\n'* || $_ble_edit_mark_active ]]; then\n            ble/widget/@nomarked backward-line\n            local status=$?\n            READLINE_LINE=$_ble_edit_str\n            READLINE_POINT=$_ble_edit_ind\n            READLINE_MARK=$_ble_edit_mark\n            return \"$status\"\n        fi\n    fi\n\n    # READLINE_LINE and READLINE_POINT are only supported by bash >= 4.0 or\n    # ble.sh.  When it is not supported, we clear them to suppress strange\n    # behaviors.\n    [[ ${BLE_ATTACHED-} ]] || ((BASH_VERSINFO[0] >= 4)) ||\n        READLINE_LINE=\"\" READLINE_POINT=0\n\n    local __atuin_output\n    __atuin_output=$(__atuin_search_cmd \"$@\")\n\n    # We do nothing when the search is canceled.\n    [[ $__atuin_output ]] || return 0\n\n    if [[ $__atuin_output == __atuin_accept__:* ]]; then\n        __atuin_output=${__atuin_output#__atuin_accept__:}\n\n        if [[ ${BLE_ATTACHED-} ]]; then\n            ble-edit/content/reset-and-check-dirty \"$__atuin_output\"\n            ble/widget/accept-line\n            READLINE_LINE=\"\"\n        elif [[ ${__atuin_macro_chain_keymap-} ]]; then\n            READLINE_LINE=$__atuin_output\n            bind -m \"$__atuin_macro_chain_keymap\" '\"'\"$__atuin_macro_chain\"'\": '\"$__atuin_macro_accept_line\"\n        else\n            __atuin_accept_line \"$__atuin_output\"\n            READLINE_LINE=\"\"\n        fi\n\n        READLINE_POINT=${#READLINE_LINE}\n    else\n        READLINE_LINE=$__atuin_output\n        READLINE_POINT=${#READLINE_LINE}\n        if [[ ! ${BLE_ATTACHED-} ]] && ((BASH_VERSINFO[0] < 4)) && [[ ${__atuin_macro_chain_keymap-} ]]; then\n            bind -m \"$__atuin_macro_chain_keymap\" '\"'\"$__atuin_macro_chain\"'\": '\"$__atuin_macro_insert_line\"\n        fi\n    fi\n}\n\n__atuin_initialize_blesh() {\n    # shellcheck disable=SC2154\n    [[ ${BLE_VERSION-} ]] && ((_ble_version >= 400)) || return 0\n\n    ble-import contrib/integration/bash-preexec\n\n    # Define and register an autosuggestion source for ble.sh's auto-complete.\n    # If you'd like to overwrite this, define the same name of shell function\n    # after the $(atuin init bash) line in your .bashrc.  If you do not need\n    # the auto-complete source by Atuin, please add the following code to\n    # remove the entry after the $(atuin init bash) line in your .bashrc:\n    #\n    #   ble/util/import/eval-after-load core-complete '\n    #     ble/array#remove _ble_complete_auto_source atuin-history'\n    #\n    function ble/complete/auto-complete/source:atuin-history {\n        local suggestion\n        suggestion=$(ATUIN_QUERY=\"$_ble_edit_str\" atuin search --cmd-only --limit 1 --search-mode prefix 2>/dev/null)\n        [[ $suggestion == \"$_ble_edit_str\"?* ]] || return 1\n        ble/complete/auto-complete/enter h 0 \"${suggestion:${#_ble_edit_str}}\" '' \"$suggestion\"\n    }\n    ble/util/import/eval-after-load core-complete '\n        ble/array#unshift _ble_complete_auto_source atuin-history'\n\n    # @env BLE_SESSION_ID: `atuin doctor` references the environment variable\n    # BLE_SESSION_ID.  We explicitly export the variable because it was not\n    # exported in older versions of ble.sh.\n    [[ ${BLE_SESSION_ID-} ]] && export BLE_SESSION_ID\n}\n__atuin_initialize_blesh\nBLE_ONLOAD+=(__atuin_initialize_blesh)\nprecmd_functions+=(__atuin_precmd)\npreexec_functions+=(__atuin_preexec)\n\n#------------------------------------------------------------------------------\n# section: atuin-bind\n\n__atuin_widget=()\n\n__atuin_widget_save() {\n    local data=$1\n    for REPLY in \"${!__atuin_widget[@]}\"; do\n        if [[ ${__atuin_widget[REPLY]} == \"$data\" ]]; then\n            return 0\n        fi\n    done\n    # shellcheck disable=SC2154\n    REPLY=${#__atuin_widget[*]}\n    __atuin_widget[REPLY]=$data\n}\n\n__atuin_widget_run() {\n    local data=${__atuin_widget[$1]}\n    local keymap=${data%%:*} widget=${data#*:}\n    local __atuin_macro_chain_keymap=$keymap\n    bind -m \"$keymap\" '\"'\"$__atuin_macro_chain\"'\": \"\"'\n    builtin eval -- \"$widget\"\n}\n\n# To realize the enter_accept feature in a robust way, we need to call the\n# readline bindable function `accept-line'.  However, there is no way to call\n# `accept-line' from the shell script.  To call the bindable function\n# `accept-line', we may utilize string macros of readline.  When we bind KEYSEQ\n# to a WIDGET that wants to conditionally call `accept-line' at the end, we\n# perform two-step dispatching:\n#\n# 1. [KEYSEQ -> IKEYSEQ1 IKEYSEQ2]---We first translate KEYSEQ to two\n#   intermediate key sequences IKEYSEQ1 and IKEYSEQ2 using string macros.  For\n#   example, when we bind `__atuin_history` to \\C-r, this step can be set up by\n#   `bind '\"\\C-r\": \"IKEYSEQ1IKEYSEQ2\"'`.\n#\n# 2. [IKEYSEQ1 -> WIDGET]---Then, IKEYSEQ1 is bound to the WIDGET, and the\n#   binding of IKEYSEQ2 is dynamically determined by WIDGET.  For example, when\n#   we bind `__atuin_history` to \\C-r, this step can be set up by `bind -x\n#   '\"IKEYSEQ1\": WIDGET'`.\n#\n# 3. [IKEYSEQ2 -> accept-line] or [IKEYSEQ2 -> \"\"]---To request the execution\n#   of `accept-line', WIDGET can change the binding of IKEYSEQ2 by running\n#   `bind '\"IKEYSEQ2\": accept-line''.  Otherwise, WIDGET can change the binding\n#   of IKEYSEQ2 to no-op by running `bind '\"IKEYSEQ2\": \"\"'`.\n#\n# For the choice of the intermediate key sequences, we want to choose key\n# sequences that are unlikely to conflict with others.  In addition, we want to\n# avoid a key sequence containing \\e because keymap \"vi-insert\" stops\n# processing key sequences containing \\e in older versions of Bash.  We have\n# used \\e[0;<m>A (a variant of the [up] key with modifier <m>) in Atuin 3.10.0\n# for intermediate key sequences, but this contains \\e and caused a problem.\n# Instead, we use \\C-x\\C-_A<n>\\a, which starts with \\C-x\\C-_ (an unlikely\n# two-byte combination) and A (represents the initial letter of Atuin),\n# followed by the payload <n> and the terminator \\a (BEL, \\C-g).\n\n__atuin_macro_chain='\\C-x\\C-_A0\\a'\nfor __atuin_keymap in emacs vi-insert vi-command; do\n    bind -m \"$__atuin_keymap\" \"\\\"$__atuin_macro_chain\\\": \\\"\\\"\"\ndone\nunset -v __atuin_keymap\n\nif ((BASH_VERSINFO[0] >= 5 || BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] >= 3)); then\n    # In Bash >= 4.3\n\n    __atuin_macro_accept_line=accept-line\n\n    __atuin_bind_impl() {\n        local keymap=$1 keyseq=$2 command=$3\n\n        # Note: In Bash <= 5.0, the table for `bind -x` from the keyseq to the\n        # command is shared by all the keymaps (emacs, vi-insert, and\n        # vi-command), so one cannot safely bind different command strings to\n        # the same keyseq in different keymaps.  Therefore, the command string\n        # and the keyseq need to be globally in one-to-one correspondence in\n        # all the keymaps.\n        local REPLY\n        __atuin_widget_save \"$keymap:$command\"\n        local widget=$REPLY\n        local ikeyseq1='\\C-x\\C-_A'$((1 + widget))'\\a'\n        local ikeyseq2=$__atuin_macro_chain\n\n        if ((BASH_VERSINFO[0] == 5 && BASH_VERSINFO[1] == 1)); then\n            # Workaround for Bash 5.1: Bash 5.1 has a bug that overwriting an\n            # existing \"bind -x\" keybinding breaks other existing \"bind -x\"\n            # keybindings [1,2].  To work around the problem, we explicitly\n            # unbind an existing keybinding before overwriting it.\n            #\n            # [1] https://lists.gnu.org/archive/html/bug-bash/2021-04/msg00135.html\n            # [2] https://github.com/atuinsh/atuin/issues/962#issuecomment-3451132291\n            bind -m \"$keymap\" -r \"$keyseq\"\n        fi\n\n        bind -m \"$keymap\" \"\\\"$keyseq\\\": \\\"$ikeyseq1$ikeyseq2\\\"\"\n        bind -m \"$keymap\" -x \"\\\"$ikeyseq1\\\": __atuin_widget_run $widget\"\n    }\n\n    __atuin_bind_blesh_onload() {\n        # In ble.sh, we need to enable unrecognized CSI sequences like \\e[0;0A,\n        # which are discarded by ble.sh by default.  Note: In Bash <= 4.2, we\n        # do not need to unset \"decode_error_cseq_discard\" because \\e[0;<m>A is\n        # used only for the macro chaining (which is unused by ble.sh) in Bash\n        # <= 4.2.\n        bleopt decode_error_cseq_discard=\n    }\n    if [[ ${BLE_VERSION-} ]]; then\n        __atuin_bind_blesh_onload\n    fi\n    BLE_ONLOAD+=(__atuin_bind_blesh_onload)\nelse\n    # In Bash <= 4.2, \"bind -x\" cannot bind a shell command to a keyseq having\n    # more than two bytes, so we need to work with only two-byte sequences.\n    #\n    # However, the number of available combinations of two-byte sequences is\n    # limited.  To minimize the number of key sequences used by Atuin, instead\n    # of specifying a widget by its own intermediate sequence, we specify a\n    # widget by a fixed-length sequence of multiple two-byte sequences.  More\n    # specifically, instead of IKEYSEQ1, we use IKS1 IKS2 IKS3 [IKS4 IKS5]\n    # IKSX, where IKS1..IKS5 just stores its information to a global variable,\n    # and IKSX collects all the information and determine and call the actual\n    # widget based on the stored information. Each of IKn (n=1..5) is one of\n    # the two reserved sequences, $__atuin_bash42_code0 and\n    # $__atuin_bash42_code1.  IKSX is fixed to be $__atuin_bash42_code2.\n    #\n    # For the choices of the special key sequences, we consider \\C-xQ, \\C-xR,\n    # and \\C-xS.  In the emacs editing mode of Bash, \\C-x is used as a prefix\n    # key, i.e., it is used for the beginning key of the keybindings with\n    # multiple keys, so \\C-x is unlikely to be used for a single-key binding by\n    # the user.  Also, \\C-x is not used in the vi editing mode by default.  The\n    # combinations \\C-xQ..\\C-xS are also unlikely be used because we need to\n    # switch the modifier keys from Control to Shift to input these sequences,\n    # and these are not easy to input.\n    __atuin_bash42_code0='\\C-xQ'\n    __atuin_bash42_code1='\\C-xR'\n    __atuin_bash42_code2='\\C-xS'\n\n    __atuin_bash42_encode() {\n        REPLY=\n        local n=$1 min_width=${2-}\n        while\n            if ((n % 2 == 0)); then\n                REPLY=$__atuin_bash42_code0$REPLY\n            else\n                REPLY=$__atuin_bash42_code1$REPLY\n            fi\n            (((n /= 2) || ${#REPLY} / ${#__atuin_bash42_code0} < min_width))\n        do :; done\n    }\n\n    __atuin_bash42_bind() {\n        local __atuin_keymap\n        for __atuin_keymap in emacs vi-insert vi-command; do\n            bind -m \"$__atuin_keymap\" -x '\"'\"$__atuin_bash42_code0\"'\": __atuin_bash42_dispatch_selector+=0'\n            bind -m \"$__atuin_keymap\" -x '\"'\"$__atuin_bash42_code1\"'\": __atuin_bash42_dispatch_selector+=1'\n            bind -m \"$__atuin_keymap\" -x '\"'\"$__atuin_bash42_code2\"'\": __atuin_bash42_dispatch'\n        done\n    }\n    __atuin_bash42_bind\n    # In Bash <= 4.2, there is no way to read users' \"bind -x\" settings, so we\n    # need to explicitly perform \"bind -x\" when ble.sh is loaded.\n    BLE_ONLOAD+=(__atuin_bash42_bind)\n\n    if ((BASH_VERSINFO[0] >= 4)); then\n        __atuin_macro_accept_line=accept-line\n    else\n        # Note: We rewrite the command line and invoke `accept-line'.  In\n        # bash <= 3.2, there is no way to rewrite the command line from the\n        # shell script, so we rewrite it using a macro and\n        # `shell-expand-line'.\n        #\n        # Note: Concerning the key sequences to invoke bindable functions\n        # such as \"\\C-x\\C-_A1\\a\", another option is to use\n        # \"\\exbegginning-of-line\\r\", etc. to make it consistent with bash\n        # >= 5.3.  However, an older Bash configuration can still conflict\n        # on [M-x].  The conflict is more likely than \\C-x\\C-_A1\\a.\n        for __atuin_keymap in emacs vi-insert vi-command; do\n            bind -m \"$__atuin_keymap\" '\"\\C-x\\C-_A1\\a\": beginning-of-line'\n            bind -m \"$__atuin_keymap\" '\"\\C-x\\C-_A2\\a\": kill-line'\n            # shellcheck disable=SC2016\n            bind -m \"$__atuin_keymap\" '\"\\C-x\\C-_A3\\a\": \"$READLINE_LINE\"'\n            bind -m \"$__atuin_keymap\" '\"\\C-x\\C-_A4\\a\": shell-expand-line'\n            bind -m \"$__atuin_keymap\" '\"\\C-x\\C-_A5\\a\": accept-line'\n            bind -m \"$__atuin_keymap\" '\"\\C-x\\C-_A6\\a\": end-of-line'\n        done\n        unset -v __atuin_keymap\n\n        bind -m vi-command '\"\\C-x\\C-_A7\\a\": vi-insertion-mode'\n        bind -m vi-insert  '\"\\C-x\\C-_A7\\a\": vi-movement-mode'\n\n        # \"\\C-x\\C-_A10\\a\": Replace the command line with READLINE_LINE.  When we are\n        #   in the vi-command keymap, we go to vi-insert, input\n        #   \"$READLINE_LINE\", and come back to vi-command.\n        bind -m emacs      '\"\\C-x\\C-_A10\\a\": \"\\C-x\\C-_A1\\a\\C-x\\C-_A2\\a\\C-x\\C-_A3\\a\\C-x\\C-_A4\\a\"'\n        bind -m vi-insert  '\"\\C-x\\C-_A10\\a\": \"\\C-x\\C-_A1\\a\\C-x\\C-_A2\\a\\C-x\\C-_A3\\a\\C-x\\C-_A4\\a\"'\n        bind -m vi-command '\"\\C-x\\C-_A10\\a\": \"\\C-x\\C-_A1\\a\\C-x\\C-_A2\\a\\C-x\\C-_A7\\a\\C-x\\C-_A3\\a\\C-x\\C-_A7\\a\\C-x\\C-_A4\\a\"'\n\n        __atuin_macro_accept_line='\"\\C-x\\C-_A10\\a\\C-x\\C-_A5\\a\"'\n        __atuin_macro_insert_line='\"\\C-x\\C-_A10\\a\\C-x\\C-_A6\\a\"'\n    fi\n\n    __atuin_bash42_dispatch_selector=\n\n    __atuin_bash42_dispatch() {\n        local s=$__atuin_bash42_dispatch_selector\n        __atuin_bash42_dispatch_selector=\n        __atuin_widget_run \"$((2#0$s))\"\n    }\n\n    __atuin_bind_impl() {\n        local keymap=$1 keyseq=$2 command=$3\n\n        __atuin_widget_save \"$keymap:$command\"\n        __atuin_bash42_encode \"$REPLY\"\n        local macro=$REPLY$__atuin_bash42_code2$__atuin_macro_chain\n\n        bind -m \"$keymap\" \"\\\"$keyseq\\\": \\\"$macro\\\"\"\n    }\nfi\n\natuin-bind() {\n    local keymap=\n    local OPTIND=1 OPTARG=\"\" OPTERR=0 flag\n    while getopts ':m:' flag \"$@\"; do\n        case $flag in\n            m) keymap=$OPTARG ;;\n            *)\n                printf '%s\\n' \"atuin-bind: unrecognized option '-$flag'\" >&2\n                return 2\n                ;;\n        esac\n    done\n    shift \"$((OPTIND - 1))\"\n\n    if (($# != 2)); then\n        printf '%s\\n' 'usage: atuin-bind [-m keymap] keyseq widget' >&2\n        return 2\n    fi\n\n    local keyseq=$1\n    [[ $keymap ]] || keymap=$(bind -v | awk '$2 == \"keymap\" { print $3 }')\n    case $keymap in\n        emacs-meta) keymap=emacs keyseq='\\e'$keyseq ;;\n        emacs-ctlx) keymap=emacs keyseq='\\C-x'$keyseq ;;\n        emacs*)     keymap=emacs ;;\n        vi-insert)  ;;\n        vi*)        keymap=vi-command ;;\n        *)\n            printf '%s\\n' \"atuin-bind: unknown keymap '$keymap'\" >&2\n            return 2 ;;\n    esac\n\n    local command=$2 widget=${2%%[[:blank:]]*}\n    case $widget in\n        atuin-search)          command=${2/#\"$widget\"/__atuin_history} ;;\n        atuin-search-emacs)    command=${2/#\"$widget\"/__atuin_history --keymap-mode=emacs} ;;\n        atuin-search-viins)    command=${2/#\"$widget\"/__atuin_history --keymap-mode=vim-insert} ;;\n        atuin-search-vicmd)    command=${2/#\"$widget\"/__atuin_history --keymap-mode=vim-normal} ;;\n        atuin-up-search)       command=${2/#\"$widget\"/__atuin_history --shell-up-key-binding} ;;\n        atuin-up-search-emacs) command=${2/#\"$widget\"/__atuin_history --shell-up-key-binding --keymap-mode=emacs} ;;\n        atuin-up-search-viins) command=${2/#\"$widget\"/__atuin_history --shell-up-key-binding --keymap-mode=vim-insert} ;;\n        atuin-up-search-vicmd) command=${2/#\"$widget\"/__atuin_history --shell-up-key-binding --keymap-mode=vim-normal} ;;\n    esac\n\n    __atuin_bind_impl \"$keymap\" \"$keyseq\" \"$command\"\n}\n\n#------------------------------------------------------------------------------\n\n# shellcheck disable=SC2154\nif [[ $__atuin_bind_ctrl_r == true ]]; then\n    # Note: We do not overwrite [C-r] in the vi-command keymap because we do\n    # not want to overwrite \"redo\", which is already bound to [C-r] in the\n    # vi_nmap keymap in ble.sh.\n    atuin-bind -m emacs      '\\C-r' atuin-search-emacs\n    atuin-bind -m vi-insert  '\\C-r' atuin-search-viins\n    atuin-bind -m vi-command '/'    atuin-search-emacs\nfi\n\n# shellcheck disable=SC2154\nif [[ $__atuin_bind_up_arrow == true ]]; then\n    atuin-bind -m emacs      '\\e[A' atuin-up-search-emacs\n    atuin-bind -m emacs      '\\eOA' atuin-up-search-emacs\n    atuin-bind -m vi-insert  '\\e[A' atuin-up-search-viins\n    atuin-bind -m vi-insert  '\\eOA' atuin-up-search-viins\n    atuin-bind -m vi-command '\\e[A' atuin-up-search-vicmd\n    atuin-bind -m vi-command '\\eOA' atuin-up-search-vicmd\n    atuin-bind -m vi-command 'k'    atuin-up-search-vicmd\nfi\n\n#------------------------------------------------------------------------------\nfi # (include guard) end of main content\n"
  },
  {
    "path": "crates/atuin/src/shell/atuin.fish",
    "content": "if not set -q ATUIN_SESSION; or test \"$ATUIN_SHLVL\" != \"$SHLVL\"\n    set -gx ATUIN_SESSION (atuin uuid)\n    set -gx ATUIN_SHLVL $SHLVL\nend\nset --erase ATUIN_HISTORY_ID\n\nfunction _atuin_preexec --on-event fish_preexec\n    if not test -n \"$fish_private_mode\"\n        set -g ATUIN_HISTORY_ID (atuin history start -- \"$argv[1]\" 2>/dev/null)\n    end\nend\n\nfunction _atuin_postexec --on-event fish_postexec\n    set -l s $status\n\n    if test -n \"$ATUIN_HISTORY_ID\"\n        ATUIN_LOG=error atuin history end --exit $s -- $ATUIN_HISTORY_ID &>/dev/null &\n        disown\n    end\n\n    set --erase ATUIN_HISTORY_ID\nend\n\n# Check if tmux popup is available (tmux >= 3.2)\nfunction _atuin_tmux_popup_check\n    if not test -n \"$TMUX\"\n        echo 0\n        return\n    end\n\n    if test \"$ATUIN_TMUX_POPUP\" = false\n        echo 0\n        return\n    end\n\n    set -l tmux_version (tmux -V 2>/dev/null | string match -r '\\d+\\.\\d+')\n    if not test -n \"$tmux_version\"\n        echo 0\n        return\n    end\n\n    set -l parts (string split '.' $tmux_version)\n    set -l m1 $parts[1]\n    set -l m2 0\n    if test (count $parts) -ge 2\n        set m2 $parts[2]\n    end\n\n    if not string match -rq '^[0-9]+$' -- \"$m1\"\n        echo 0\n        return\n    end\n\n    if not string match -rq '^[0-9]+$' -- \"$m2\"\n        set m2 0\n    end\n\n    if test \"$m1\" -gt 3 2>/dev/null; or begin\n            test \"$m1\" -eq 3 2>/dev/null; and test \"$m2\" -ge 2 2>/dev/null\n        end\n        echo 1\n    else\n        echo 0\n    end\nend\n\nfunction _atuin_search\n    set -l keymap_mode\n    switch $fish_key_bindings\n        case fish_vi_key_bindings\n            switch $fish_bind_mode\n                case default\n                    set keymap_mode vim-normal\n                case insert\n                    set keymap_mode vim-insert\n            end\n        case '*'\n            set keymap_mode emacs\n    end\n\n    set -l use_tmux_popup (_atuin_tmux_popup_check)\n\n    set -l ATUIN_H\n    if test \"$use_tmux_popup\" -eq 1\n        set -l tmpdir (mktemp -d)\n        if not test -d \"$tmpdir\"\n            # if mktemp got errors\n            set ATUIN_H (ATUIN_SHELL=fish ATUIN_LOG=error ATUIN_QUERY=(commandline -b) atuin search --keymap-mode=$keymap_mode $argv -i 3>&1 1>&2 2>&3 | string collect)\n        else\n            set -l result_file \"$tmpdir/result\"\n\n            set -l query (commandline -b | string replace -a \"'\" \"'\\\\''\")\n            set -l escaped_args \"\"\n            for arg in $argv\n                set escaped_args \"$escaped_args '\"(string replace -a \"'\" \"'\\\\''\" -- $arg)\"'\"\n            end\n\n            # In the popup, atuin goes to terminal, stderr goes to file\n            set -l cdir (pwd)\n            # Keep default value anyways\n            set -l popup_width (test -n \"$ATUIN_TMUX_POPUP_WIDTH\" && echo \"$ATUIN_TMUX_POPUP_WIDTH\" || echo \"80%\")\n            set -l popup_height (test -n \"$ATUIN_TMUX_POPUP_HEIGHT\" && echo \"$ATUIN_TMUX_POPUP_HEIGHT\" || echo \"60%\")\n            tmux display-popup -d \"$cdir\" -w \"$popup_width\" -h \"$popup_height\" -E -E -- \\\n                sh -c \"PATH='$PATH' ATUIN_SESSION='$ATUIN_SESSION' ATUIN_SHELL=fish ATUIN_LOG=error ATUIN_QUERY='$query' atuin search --keymap-mode=$keymap_mode$escaped_args -i 2>'$result_file'\"\n\n            if test -f \"$result_file\"\n                set ATUIN_H (cat \"$result_file\" | string collect)\n            end\n\n            command rm -rf \"$tmpdir\"\n        end\n    else\n        # In fish 3.4 and above we can use `\"$(some command)\"` to keep multiple lines separate;\n        # but to support fish 3.3 we need to use `(some command | string collect)`.\n        # https://fishshell.com/docs/current/relnotes.html#id24 (fish 3.4 \"Notable improvements and fixes\")\n        set ATUIN_H (ATUIN_SHELL=fish ATUIN_LOG=error ATUIN_QUERY=(commandline -b) atuin search --keymap-mode=$keymap_mode $argv -i 3>&1 1>&2 2>&3 | string collect)\n    end\n\n    set ATUIN_H (string trim -- $ATUIN_H | string collect) # trim whitespace\n\n    if test -n \"$ATUIN_H\"\n        if string match --quiet '__atuin_accept__:*' \"$ATUIN_H\"\n            set -l ATUIN_HIST (string replace \"__atuin_accept__:\" \"\" -- \"$ATUIN_H\" | string collect)\n            commandline -r \"$ATUIN_HIST\"\n            commandline -f repaint\n            commandline -f execute\n            return\n        else\n            commandline -r \"$ATUIN_H\"\n        end\n    end\n\n    commandline -f repaint\nend\n\nfunction _atuin_bind_up\n    # Fallback to fish's builtin up-or-search if we're in search or paging mode\n    if commandline --search-mode; or commandline --paging-mode\n        up-or-search\n        return\n    end\n\n    # Only invoke atuin if we're on the top line of the command\n    set -l lineno (commandline --line)\n\n    switch $lineno\n        case 1\n            _atuin_search --shell-up-key-binding\n        case '*'\n            up-or-search\n    end\nend\n"
  },
  {
    "path": "crates/atuin/src/shell/atuin.nu",
    "content": "# Source this in your ~/.config/nushell/config.nu\n# minimum supported version = 0.93.0\nmodule compat {\n  export def --wrapped \"random uuid -v 7\" [...rest] { atuin uuid }\n}\nuse (if not (\n    (version).major > 0 or\n    (version).minor >= 103\n) { \"compat\" }) *\n\nif 'ATUIN_SESSION' not-in $env or ('ATUIN_SHLVL' not-in $env) or ($env.ATUIN_SHLVL != ($env.SHLVL? | default \"\")) {\n    $env.ATUIN_SESSION = (random uuid -v 7 | str replace -a \"-\" \"\")\n    $env.ATUIN_SHLVL = ($env.SHLVL? | default \"\")\n}\nhide-env -i ATUIN_HISTORY_ID\n\n# Magic token to make sure we don't record commands run by keybindings\nlet ATUIN_KEYBINDING_TOKEN = $\"# (random uuid)\"\n\nlet _atuin_pre_execution = {||\n    if ($nu | get history-enabled?) == false {\n        return\n    }\n    let cmd = (commandline)\n    if ($cmd | is-empty) {\n        return\n    }\n    if not ($cmd | str starts-with $ATUIN_KEYBINDING_TOKEN) {\n        $env.ATUIN_HISTORY_ID = (atuin history start -- $cmd e>| complete | get stdout | str trim)\n    }\n}\n\nlet _atuin_pre_prompt = {||\n    let last_exit = $env.LAST_EXIT_CODE\n    if 'ATUIN_HISTORY_ID' not-in $env {\n        return\n    }\n    with-env { ATUIN_LOG: error } {\n        if (version).minor >= 104 or (version).major > 0 {\n            job spawn {\n                ^atuin history end $'--exit=($env.LAST_EXIT_CODE)' -- $env.ATUIN_HISTORY_ID | complete\n            } | ignore\n        } else {\n            do { atuin history end $'--exit=($last_exit)' -- $env.ATUIN_HISTORY_ID } | complete\n        }\n\n    }\n    hide-env ATUIN_HISTORY_ID\n}\n\ndef _atuin_search_cmd [...flags: string] {\n    if (version).minor >= 106 or (version).major > 0 {\n        [\n            $ATUIN_KEYBINDING_TOKEN,\n            ([\n                `with-env { ATUIN_LOG: error, ATUIN_QUERY: (commandline), ATUIN_SHELL: nu } {`,\n                    ([\n                        'let output = (run-external atuin search',\n                        ($flags | append [--interactive] | each {|e| $'\"($e)\"'}),\n                        'e>| str trim)',\n                    ] | flatten | str join ' '),\n                    'if ($output | str starts-with \"__atuin_accept__:\") {',\n                    'commandline edit --accept ($output | str replace \"__atuin_accept__:\" \"\")',\n                    '} else {',\n                    'commandline edit $output',\n                    '}',\n                `}`,\n            ] | flatten | str join \"\\n\"),\n        ]\n    } else {\n        [\n            $ATUIN_KEYBINDING_TOKEN,\n            ([\n                `with-env { ATUIN_LOG: error, ATUIN_QUERY: (commandline) } {`,\n                    'commandline edit',\n                    '(run-external atuin search',\n                        ($flags | append [--interactive] | each {|e| $'\"($e)\"'}),\n                    ' e>| str trim)',\n                `}`,\n            ] | flatten | str join ' '),\n        ]\n    } | str join \"\\n\"\n}\n\n$env.config = ($env | default {} config).config\n$env.config = ($env.config | default {} hooks)\n$env.config = (\n    $env.config | upsert hooks (\n        $env.config.hooks\n        | upsert pre_execution (\n            $env.config.hooks | get pre_execution? | default [] | append $_atuin_pre_execution)\n        | upsert pre_prompt (\n            $env.config.hooks | get pre_prompt? | default [] | append $_atuin_pre_prompt)\n    )\n)\n\n$env.config = ($env.config | default [] keybindings)\n"
  },
  {
    "path": "crates/atuin/src/shell/atuin.ps1",
    "content": "# Atuin PowerShell module\n#\n# This should support PowerShell 5.1 (which is shipped with Windows) and later versions, on Windows and Linux.\n#\n# Usage: atuin init powershell | Out-String | Invoke-Expression\n#\n# Settings:\n# - $env:ATUIN_POWERSHELL_PROMPT_OFFSET - Number of lines to offset the prompt position after exiting search.\n#   This is useful when using a multi-line prompt: e.g. set this to -1 when using a 2-line prompt.\n#   It is initialized from the current prompt line count if not set when the first Atuin search is performed.\n\nif (Get-Module Atuin -ErrorAction Ignore) {\n    if ($PSVersionTable.PSVersion.Major -ge 7) {\n        Write-Warning \"The Atuin module is already loaded, replacing it.\"\n        Remove-Module Atuin\n    } else {\n        Write-Warning \"The Atuin module is already loaded, skipping.\"\n        return\n    }\n}\n\nif (!(Get-Command atuin -ErrorAction Ignore)) {\n    Write-Error \"The 'atuin' executable needs to be available in the PATH.\"\n    return\n}\n\nif (!(Get-Module PSReadLine -ErrorAction Ignore)) {\n    Write-Error \"Atuin requires the PSReadLine module to be installed.\"\n    return\n}\n\nNew-Module -Name Atuin -ScriptBlock {\n    if (-not $env:ATUIN_SESSION -or $env:ATUIN_PID -ne $PID) {\n        $env:ATUIN_SESSION = atuin uuid\n        $env:ATUIN_PID = $PID\n    }\n\n    $script:atuinHistoryId = $null\n    $script:previousPSConsoleHostReadLine = $Function:PSConsoleHostReadLine\n\n    # The ReadLine overloads changed with breaking changes over time, make sure the one we expect is available.\n    $script:hasExpectedReadLineOverload = ([Microsoft.PowerShell.PSConsoleReadLine]::ReadLine).OverloadDefinitions.Contains(\"static string ReadLine(runspace runspace, System.Management.Automation.EngineIntrinsics engineIntrinsics, System.Threading.CancellationToken cancellationToken, System.Nullable[bool] lastRunStatus)\")\n\n    function Get-CommandLine {\n        $commandLine = \"\"\n        [Microsoft.PowerShell.PSConsoleReadLine]::GetBufferState([ref]$commandLine, [ref]$null)\n        return $commandLine\n    }\n\n    function Set-CommandLine {\n        param([string]$Text)\n\n        $commandLine = Get-CommandLine\n        [Microsoft.PowerShell.PSConsoleReadLine]::Replace(0, $commandLine.Length, $Text)\n    }\n\n    # This function name is called by PSReadLine to read the next command line to execute.\n    # We replace it with a custom implementation which adds Atuin support.\n    function PSConsoleHostReadLine {\n        ## 1. Collect the exit code of the previous command.\n\n        # This needs to be done as the first thing because any script run will flush $?.\n        $lastRunStatus = $?\n\n        # Exit statuses are maintained separately for native and PowerShell commands, this needs to be taken into account.\n        $lastNativeExitCode = $global:LASTEXITCODE\n        $exitCode = if ($lastRunStatus) { 0 } elseif ($lastNativeExitCode) { $lastNativeExitCode } else { 1 }\n\n        ## 2. Report the status of the previous command to Atuin (atuin history end).\n\n        if ($script:atuinHistoryId) {\n            try {\n                # The duration is not recorded in old PowerShell versions, let Atuin handle it. $null arguments are ignored.\n                $duration = (Get-History -Count 1).Duration.Ticks * 100\n                $durationArg = if ($duration) { \"--duration=$duration\" } else { $null }\n\n                # Fire and forget the atuin history end command to avoid blocking the shell during a potential sync.\n                $process = New-Object System.Diagnostics.Process\n                $process.StartInfo.FileName = \"atuin\"\n                $process.StartInfo.Arguments = \"history end --exit=$exitCode $durationArg -- $script:atuinHistoryId\"\n                $process.StartInfo.UseShellExecute = $false\n                $process.StartInfo.CreateNoWindow = $true\n                $process.StartInfo.RedirectStandardInput = $true\n                $process.StartInfo.RedirectStandardOutput = $true\n                $process.StartInfo.RedirectStandardError = $true\n                $process.Start() | Out-Null\n                $process.StandardInput.Close()\n                $process.BeginOutputReadLine()\n                $process.BeginErrorReadLine()\n            }\n            catch {\n                # Ignore errors to avoid breaking the shell.\n                # An error would occur if the user removes atuin from the PATH, for instance.\n            }\n            finally {\n                $script:atuinHistoryId = $null\n            }\n        }\n\n        ## 3. Read the next command line to execute.\n\n        # PSConsoleHostReadLine implementation from PSReadLine, adjusted to support old versions.\n        Microsoft.PowerShell.Core\\Set-StrictMode -Off\n\n        $line = if ($script:hasExpectedReadLineOverload) {\n            # When the overload we expect is available, we can pass $lastRunStatus to it.\n            [Microsoft.PowerShell.PSConsoleReadLine]::ReadLine($Host.Runspace, $ExecutionContext, [System.Threading.CancellationToken]::None, $lastRunStatus)\n        } else {\n            # Either PSReadLine is older than v2.2.0-beta3, or maybe newer than we expect, so use the function from PSReadLine as-is.\n            & $script:previousPSConsoleHostReadLine\n        }\n\n        ## 4. Report the next command line to Atuin (atuin history start).\n\n        # PowerShell doesn't handle double quotes in native command line arguments the same way depending on its version,\n        # and the value of $PSNativeCommandArgumentPassing - see the about_Parsing help page which explains the breaking changes.\n        # This makes it unreliable, so we go through an environment variable, which should always be consistent across versions.\n        try {\n            $env:ATUIN_COMMAND_LINE = $line\n            $script:atuinHistoryId = atuin history start --command-from-env\n        }\n        catch {\n            # Ignore errors to avoid breaking the shell, see above.\n        }\n        finally {\n            $env:ATUIN_COMMAND_LINE = $null\n        }\n\n        $global:LASTEXITCODE = $lastNativeExitCode\n        return $line\n    }\n\n    function Invoke-AtuinSearch {\n        param([string]$ExtraArgs = \"\")\n\n        $previousOutputEncoding = [System.Console]::OutputEncoding\n        $resultFile = New-TemporaryFile\n        $suggestion = \"\"\n        $errorOutput = \"\"\n\n        try {\n            [System.Console]::OutputEncoding = [System.Text.Encoding]::UTF8\n\n            # Start-Process does some crazy stuff, just use the Process class directly to have more control.\n            $process = New-Object System.Diagnostics.Process\n            $process.StartInfo.FileName = \"atuin\"\n            $process.StartInfo.Arguments = \"search -i --result-file \"\"$resultFile\"\" $ExtraArgs\"\n            $process.StartInfo.UseShellExecute = $false\n            $process.StartInfo.RedirectStandardError = $true\n            $process.StartInfo.StandardErrorEncoding = [System.Text.Encoding]::UTF8\n            $process.StartInfo.EnvironmentVariables[\"ATUIN_SHELL\"] = \"powershell\"\n            $process.StartInfo.EnvironmentVariables[\"ATUIN_QUERY\"] = Get-CommandLine\n\n            try {\n                $process.Start() | Out-Null\n\n                # A single stream is redirected, so we can read it synchronously, but we have to start reading it\n                # before waiting for the process to exit, otherwise the buffer could fill up and cause a deadlock.\n                $errorOutput = $process.StandardError.ReadToEnd().Trim()\n                $process.WaitForExit()\n\n                $suggestion = (Get-Content -Raw $resultFile -Encoding UTF8 | Out-String).Trim()\n            }\n            catch {\n                $errorOutput = $_\n            }\n\n            if ($errorOutput) {\n                Write-Host -ForegroundColor Red \"Atuin error:\"\n                Write-Host -ForegroundColor DarkRed $errorOutput\n            }\n\n            # If no shell prompt offset is set, initialize it from the current prompt line count.\n            if ($null -eq $env:ATUIN_POWERSHELL_PROMPT_OFFSET) {\n                try {\n                    $promptLines = (& $Function:prompt | Out-String | Measure-Object -Line).Lines\n                    $env:ATUIN_POWERSHELL_PROMPT_OFFSET = -1 * ($promptLines - 1)\n                }\n                catch {\n                    $env:ATUIN_POWERSHELL_PROMPT_OFFSET = 0\n                }\n            }\n\n            # PSReadLine maintains its own cursor position, which will no longer be valid if Atuin scrolls the display in inline mode.\n            # Fortunately, InvokePrompt can receive a new Y position and reset the internal state.\n            $y = $Host.UI.RawUI.CursorPosition.Y + [int]$env:ATUIN_POWERSHELL_PROMPT_OFFSET\n            $y = [System.Math]::Max([System.Math]::Min($y, [System.Console]::BufferHeight - 1), 0)\n            [Microsoft.PowerShell.PSConsoleReadLine]::InvokePrompt($null, $y)\n\n            if ($suggestion -eq \"\") {\n                # The previous input was already rendered by InvokePrompt\n                return\n            }\n\n            $acceptPrefix = \"__atuin_accept__:\"\n\n            if ( $suggestion.StartsWith($acceptPrefix)) {\n                Set-CommandLine $suggestion.Substring($acceptPrefix.Length)\n                [Microsoft.PowerShell.PSConsoleReadLine]::AcceptLine()\n            } else {\n                Set-CommandLine $suggestion\n            }\n        }\n        finally {\n            [System.Console]::OutputEncoding = $previousOutputEncoding\n            Remove-Item $resultFile\n        }\n    }\n\n    function Enable-AtuinSearchKeys {\n        param([bool]$CtrlR = $true, [bool]$UpArrow = $true)\n\n        if ($CtrlR) {\n            Set-PSReadLineKeyHandler -Chord \"Ctrl+r\" -BriefDescription \"Runs Atuin search\" -ScriptBlock {\n                Invoke-AtuinSearch\n            }\n        }\n\n        if ($UpArrow) {\n            Set-PSReadLineKeyHandler -Chord \"UpArrow\" -BriefDescription \"Runs Atuin search\" -ScriptBlock {\n                $line = Get-CommandLine\n\n                if (!$line.Contains(\"`n\")) {\n                    Invoke-AtuinSearch -ExtraArgs \"--shell-up-key-binding\"\n                } else {\n                    [Microsoft.PowerShell.PSConsoleReadLine]::PreviousLine()\n                }\n            }\n        }\n    }\n\n    $ExecutionContext.SessionState.Module.OnRemove += {\n        $env:ATUIN_SESSION = $null\n        $Function:PSConsoleHostReadLine = $script:previousPSConsoleHostReadLine\n    }\n\n    Export-ModuleMember -Function @(\"Enable-AtuinSearchKeys\", \"PSConsoleHostReadLine\")\n} | Import-Module -Global\n"
  },
  {
    "path": "crates/atuin/src/shell/atuin.xsh",
    "content": "import subprocess\n\nfrom prompt_toolkit.application.current import get_app\nfrom prompt_toolkit.filters import Condition\nfrom prompt_toolkit.keys import Keys\n\n\nif \"ATUIN_SESSION\" not in ${...} or ${...}.get(\"ATUIN_SHLVL\", \"\") != ${...}.get(\"SHLVL\", \"\"):\n    $ATUIN_SESSION=$(atuin uuid).rstrip('\\n')\n    $ATUIN_SHLVL = ${...}.get(\"SHLVL\", \"\")\n\n@events.on_precommand\ndef _atuin_precommand(cmd: str):\n    cmd = cmd.rstrip(\"\\n\")\n    try:\n        $ATUIN_HISTORY_ID = $(atuin history start -- @(cmd) 2>/dev/null).rstrip(\"\\n\")\n    except:\n        $ATUIN_HISTORY_ID = \"\"\n\n\n@events.on_postcommand\ndef _atuin_postcommand(cmd: str, rtn: int, out, ts):\n    if \"ATUIN_HISTORY_ID\" not in ${...}:\n        return\n\n    duration = ts[1] - ts[0]\n    # Duration is float representing seconds, but atuin expects integer of nanoseconds\n    nanos = round(duration * 10 ** 9)\n    with ${...}.swap(ATUIN_LOG=\"error\"):\n        # This causes the entire .xonshrc to be re-executed, which is incredibly slow\n        # This happens when using a subshell and using output redirection at the same time\n        # For more details, see https://github.com/xonsh/xonsh/issues/5224\n        # (atuin history end --exit @(rtn) -- $ATUIN_HISTORY_ID &) > /dev/null 2>&1\n        atuin history end --exit @(rtn) --duration @(nanos) -- $ATUIN_HISTORY_ID > /dev/null 2>&1\n    del $ATUIN_HISTORY_ID\n\n\ndef _search(event, extra_args: list[str]):\n    buffer = event.current_buffer\n    cmd = [\"atuin\", \"search\", \"--interactive\", *extra_args]\n    # We need to explicitly pass in xonsh env, in case user has set XDG_HOME or something else that matters\n    env = ${...}.detype()\n    env[\"ATUIN_SHELL\"] = \"xonsh\"\n    env[\"ATUIN_QUERY\"] = buffer.text\n\n    p = subprocess.run(cmd, stderr=subprocess.PIPE, encoding=\"utf-8\", env=env)\n    result = p.stderr.rstrip(\"\\n\")\n    # redraw prompt - necessary if atuin is configured to run inline, rather than fullscreen\n    event.cli.renderer.erase()\n\n    if not result:\n        return\n\n    buffer.reset()\n    if result.startswith(\"__atuin_accept__:\"):\n        buffer.insert_text(result[17:])\n        buffer.validate_and_handle()\n    else:\n        buffer.insert_text(result)\n\n\n@events.on_ptk_create\ndef _custom_keybindings(bindings, **kw):\n    if _ATUIN_BIND_CTRL_R:\n        @bindings.add(Keys.ControlR)\n        def r_search(event):\n            _search(event, extra_args=[])\n\n    if _ATUIN_BIND_UP_ARROW:\n        @Condition\n        def should_search():\n            buffer = get_app().current_buffer\n            # disable keybind when there is an active completion, so\n            # that up arrow can be used to navigate completion menu\n            if buffer.complete_state is not None:\n                return False\n            # similarly, disable when buffer text contains multiple lines\n            if '\\n' in buffer.text:\n                return False\n\n            return True\n\n        @bindings.add(Keys.Up, filter=should_search)\n        def up_search(event):\n            _search(event, extra_args=[\"--shell-up-key-binding\"])\n"
  },
  {
    "path": "crates/atuin/src/shell/atuin.zsh",
    "content": "# shellcheck disable=SC2034,SC2153,SC2086,SC2155\n\n# Above line is because shellcheck doesn't support zsh, per\n# https://github.com/koalaman/shellcheck/wiki/SC1071, and the ignore: param in\n# ludeeus/action-shellcheck only supports _directories_, not _files_. So\n# instead, we manually add any error the shellcheck step finds in the file to\n# the above line ...\n\n# Source this in your ~/.zshrc\nautoload -U add-zsh-hook\n\nzmodload zsh/datetime 2>/dev/null\n\n# If zsh-autosuggestions is installed, configure it to use Atuin's search. If\n# you'd like to override this, then add your config after the $(atuin init zsh)\n# in your .zshrc\n_zsh_autosuggest_strategy_atuin() {\n    # silence errors, since we don't want to spam the terminal prompt while typing.\n    suggestion=$(ATUIN_QUERY=\"$1\" atuin search --cmd-only --limit 1 --search-mode prefix 2>/dev/null)\n}\n\nif [ -n \"${ZSH_AUTOSUGGEST_STRATEGY:-}\" ]; then\n    ZSH_AUTOSUGGEST_STRATEGY=(\"atuin\" \"${ZSH_AUTOSUGGEST_STRATEGY[@]}\")\nelse\n    ZSH_AUTOSUGGEST_STRATEGY=(\"atuin\")\nfi\n\nif [[ -z \"${ATUIN_SESSION:-}\" || \"${ATUIN_SHLVL:-}\" != \"$SHLVL\" ]]; then\n    export ATUIN_SESSION=$(atuin uuid)\n    export ATUIN_SHLVL=$SHLVL\nfi\nATUIN_HISTORY_ID=\"\"\n\n_atuin_preexec() {\n    local id\n    id=$(atuin history start -- \"$1\" 2>/dev/null)\n    export ATUIN_HISTORY_ID=\"$id\"\n    __atuin_preexec_time=${EPOCHREALTIME-}\n}\n\n_atuin_precmd() {\n    local EXIT=\"$?\" __atuin_precmd_time=${EPOCHREALTIME-}\n\n    [[ -z \"${ATUIN_HISTORY_ID:-}\" ]] && return\n\n    local duration=\"\"\n    if [[ -n $__atuin_preexec_time && -n $__atuin_precmd_time ]]; then\n        printf -v duration %.0f $(((__atuin_precmd_time - __atuin_preexec_time) * 1000000000))\n    fi\n\n    (ATUIN_LOG=error atuin history end --exit $EXIT ${duration:+--duration=$duration} -- $ATUIN_HISTORY_ID &) >/dev/null 2>&1\n    export ATUIN_HISTORY_ID=\"\"\n}\n\n# Check if tmux popup is available (tmux >= 3.2)\n__atuin_tmux_popup_check() {\n    [[ -n \"${TMUX-}\" ]] || return 1\n    [[ \"${ATUIN_TMUX_POPUP:-true}\" != \"false\" ]] || return 1\n\n    # https://github.com/tmux/tmux/wiki/FAQ#how-often-is-tmux-released-what-is-the-version-number-scheme\n    local tmux_version\n    tmux_version=$(tmux -V 2>/dev/null | sed -n 's/^[^0-9]*\\([0-9][0-9]*\\.[0-9][0-9]*\\).*/\\1/p') # Could have used grep...\n    [[ -z \"$tmux_version\" ]] && return 1\n\n    local m1 m2\n    m1=${tmux_version%%.*}\n    m2=${tmux_version#*.}\n    m2=${m2%%.*}\n    [[ \"$m1\" =~ ^[0-9]+$ ]] || return 1\n    [[ \"$m2\" =~ ^[0-9]+$ ]] || m2=0\n    (( m1 > 3 || (m1 == 3 && m2 >= 2) ))\n}\n\n# Use global variable to fix scope issues with traps\n__atuin_popup_tmpdir=\"\"\n__atuin_tmux_popup_cleanup() {\n    [[ -n \"$__atuin_popup_tmpdir\" && -d \"$__atuin_popup_tmpdir\" ]] && command rm -rf \"$__atuin_popup_tmpdir\"\n    __atuin_popup_tmpdir=\"\"\n}\n\n__atuin_search_cmd() {\n    local -a search_args=(\"$@\")\n\n    if __atuin_tmux_popup_check; then\n        __atuin_popup_tmpdir=$(mktemp -d) || return 1\n        local result_file=\"$__atuin_popup_tmpdir/result\"\n\n        trap '__atuin_tmux_popup_cleanup' EXIT HUP INT TERM\n\n        local escaped_query escaped_args\n        escaped_query=$(printf '%s' \"$BUFFER\" | sed \"s/'/'\\\\\\\\''/g\")\n        escaped_args=\"\"\n        for arg in \"${search_args[@]}\"; do\n            escaped_args+=\" '$(printf '%s' \"$arg\" | sed \"s/'/'\\\\\\\\''/g\")'\"\n        done\n\n        # In the popup, atuin goes to terminal, stderr goes to file\n        local cdir popup_width popup_height\n        cdir=$(pwd)\n        popup_width=\"${ATUIN_TMUX_POPUP_WIDTH:-80%}\" # Keep default value anyways\n        popup_height=\"${ATUIN_TMUX_POPUP_HEIGHT:-60%}\"\n        tmux display-popup -d \"$cdir\" -w \"$popup_width\" -h \"$popup_height\" -E -E -- \\\n            sh -c \"PATH='$PATH' ATUIN_SESSION='$ATUIN_SESSION' ATUIN_SHELL=zsh ATUIN_LOG=error ATUIN_QUERY='$escaped_query' atuin search $escaped_args -i 2>'$result_file'\"\n\n        if [[ -f \"$result_file\" ]]; then\n            cat \"$result_file\"\n        fi\n\n        __atuin_tmux_popup_cleanup\n        trap - EXIT HUP INT TERM\n    else\n        ATUIN_SHELL=zsh ATUIN_LOG=error ATUIN_QUERY=$BUFFER atuin search \"${search_args[@]}\" -i 3>&1 1>&2 2>&3\n    fi\n}\n\n_atuin_search() {\n    emulate -L zsh\n    zle -I\n\n    # swap stderr and stdout, so that the tui stuff works\n    # TODO: not this\n    local output\n    # shellcheck disable=SC2048\n    output=$(__atuin_search_cmd $*)\n\n    zle reset-prompt\n    # re-enable bracketed paste\n    # shellcheck disable=SC2154\n    echo -n ${zle_bracketed_paste[1]} >/dev/tty\n\n    if [[ -n $output ]]; then\n        RBUFFER=\"\"\n        LBUFFER=$output\n\n        if [[ $LBUFFER == __atuin_accept__:* ]]\n        then\n            LBUFFER=${LBUFFER#__atuin_accept__:}\n            zle accept-line\n        fi\n    fi\n}\n_atuin_search_vicmd() {\n    _atuin_search --keymap-mode=vim-normal\n}\n_atuin_search_viins() {\n    _atuin_search --keymap-mode=vim-insert\n}\n\n_atuin_up_search() {\n    # Only trigger if the buffer is a single line\n    if [[ ! $BUFFER == *$'\\n'* ]]; then\n        _atuin_search --shell-up-key-binding \"$@\"\n    else\n        zle up-line\n    fi\n}\n_atuin_up_search_vicmd() {\n    _atuin_up_search --keymap-mode=vim-normal\n}\n_atuin_up_search_viins() {\n    _atuin_up_search --keymap-mode=vim-insert\n}\n\nadd-zsh-hook preexec _atuin_preexec\nadd-zsh-hook precmd _atuin_precmd\n\nzle -N atuin-search _atuin_search\nzle -N atuin-search-vicmd _atuin_search_vicmd\nzle -N atuin-search-viins _atuin_search_viins\nzle -N atuin-up-search _atuin_up_search\nzle -N atuin-up-search-vicmd _atuin_up_search_vicmd\nzle -N atuin-up-search-viins _atuin_up_search_viins\n\n# These are compatibility widget names for \"atuin <= 17.2.1\" users.\nzle -N _atuin_search_widget _atuin_search\nzle -N _atuin_up_search_widget _atuin_up_search\n"
  },
  {
    "path": "crates/atuin/src/sync.rs",
    "content": "use atuin_dotfiles::store::{AliasStore, var::VarStore};\nuse atuin_scripts::store::ScriptStore;\nuse eyre::{Context, Result};\n\nuse atuin_client::{\n    database::Database, history::store::HistoryStore, record::sqlite_store::SqliteStore,\n    settings::Settings,\n};\nuse atuin_common::record::RecordId;\nuse atuin_kv::store::KvStore;\n\n// This is the only crate that ties together all other crates.\n// Therefore, it's the only crate where functions tying together all stores can live\n\n/// Rebuild all stores after a sync\n/// Note: for history, this only does an _incremental_ sync. Hence the need to specify downloaded\n/// records.\npub async fn build(\n    settings: &Settings,\n    store: &SqliteStore,\n    db: &dyn Database,\n    downloaded: Option<&[RecordId]>,\n) -> Result<()> {\n    let encryption_key: [u8; 32] = atuin_client::encryption::load_key(settings)\n        .context(\"could not load encryption key\")?\n        .into();\n\n    let host_id = Settings::host_id().await?;\n\n    let downloaded = downloaded.unwrap_or(&[]);\n\n    let kv_db = atuin_kv::database::Database::new(settings.kv.db_path.clone(), 1.0).await?;\n\n    let history_store = HistoryStore::new(store.clone(), host_id, encryption_key);\n    let alias_store = AliasStore::new(store.clone(), host_id, encryption_key);\n    let var_store = VarStore::new(store.clone(), host_id, encryption_key);\n    let kv_store = KvStore::new(store.clone(), kv_db, host_id, encryption_key);\n    let script_store = ScriptStore::new(store.clone(), host_id, encryption_key);\n\n    history_store.incremental_build(db, downloaded).await?;\n\n    alias_store.build().await?;\n    var_store.build().await?;\n    kv_store.build().await?;\n\n    let script_db =\n        atuin_scripts::database::Database::new(settings.scripts.db_path.clone(), 1.0).await?;\n    script_store.build(script_db).await?;\n    Ok(())\n}\n"
  },
  {
    "path": "crates/atuin/tests/common/mod.rs",
    "content": "use std::{env, time::Duration};\n\nuse atuin_client::api_client;\nuse atuin_common::utils::uuid_v7;\nuse atuin_server::{Settings as ServerSettings, launch_with_tcp_listener};\nuse atuin_server_database::DbSettings;\nuse atuin_server_postgres::Postgres;\nuse futures_util::TryFutureExt;\nuse tokio::{net::TcpListener, sync::oneshot, task::JoinHandle};\nuse tracing::{Dispatch, dispatcher};\nuse tracing_subscriber::{EnvFilter, layer::SubscriberExt};\n\npub async fn start_server(path: &str) -> (String, oneshot::Sender<()>, JoinHandle<()>) {\n    let formatting_layer = tracing_tree::HierarchicalLayer::default()\n        .with_writer(tracing_subscriber::fmt::TestWriter::new())\n        .with_indent_lines(true)\n        .with_ansi(true)\n        .with_targets(true)\n        .with_indent_amount(2);\n\n    let dispatch: Dispatch = tracing_subscriber::registry()\n        .with(formatting_layer)\n        .with(EnvFilter::new(\"atuin_server=debug,atuin_client=debug,info\"))\n        .into();\n\n    let db_uri = env::var(\"ATUIN_DB_URI\")\n        .unwrap_or_else(|_| \"postgres://atuin:pass@localhost:5432/atuin\".to_owned());\n\n    let server_settings = ServerSettings {\n        host: \"127.0.0.1\".to_owned(),\n        port: 0,\n        path: path.to_owned(),\n        sync_v1_enabled: true,\n        open_registration: true,\n        max_history_length: 8192,\n        max_record_size: 1024 * 1024 * 1024,\n        page_size: 1100,\n        register_webhook_url: None,\n        register_webhook_username: String::new(),\n        db_settings: DbSettings {\n            db_uri: db_uri,\n            read_db_uri: None,\n        },\n        metrics: atuin_server::settings::Metrics::default(),\n        fake_version: None,\n    };\n\n    let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel();\n    let listener = TcpListener::bind(\"127.0.0.1:0\").await.unwrap();\n    let addr = listener.local_addr().unwrap();\n    let server = tokio::spawn(async move {\n        let _tracing_guard = dispatcher::set_default(&dispatch);\n\n        if let Err(e) = launch_with_tcp_listener::<Postgres>(\n            server_settings,\n            listener,\n            shutdown_rx.unwrap_or_else(|_| ()),\n        )\n        .await\n        {\n            tracing::error!(error=?e, \"server error\");\n            panic!(\"error running server: {e:?}\");\n        }\n    });\n\n    // let the server come online\n    tokio::time::sleep(Duration::from_millis(200)).await;\n\n    (format!(\"http://{addr}{path}\"), shutdown_tx, server)\n}\n\npub async fn register_inner<'a>(\n    address: &'a str,\n    username: &str,\n    password: &str,\n) -> api_client::Client<'a> {\n    let email = format!(\"{}@example.com\", uuid_v7().as_simple());\n\n    // registration works\n    let registration_response = api_client::register(address, username, &email, password)\n        .await\n        .unwrap();\n\n    api_client::Client::new(\n        address,\n        api_client::AuthToken::Token(registration_response.session),\n        5,\n        30,\n    )\n    .unwrap()\n}\n\n#[allow(dead_code)]\npub async fn login(address: &str, username: String, password: String) -> api_client::Client<'_> {\n    // registration works\n    let login_response = api_client::login(\n        address,\n        atuin_common::api::LoginRequest { username, password },\n    )\n    .await\n    .unwrap();\n\n    api_client::Client::new(\n        address,\n        api_client::AuthToken::Token(login_response.session),\n        5,\n        30,\n    )\n    .unwrap()\n}\n\n#[allow(dead_code)]\npub async fn register(address: &str) -> api_client::Client<'_> {\n    let username = uuid_v7().as_simple().to_string();\n    let password = uuid_v7().as_simple().to_string();\n    register_inner(address, &username, &password).await\n}\n"
  },
  {
    "path": "crates/atuin/tests/sync.rs",
    "content": "use atuin_common::{api::AddHistoryRequest, utils::uuid_v7};\nuse time::OffsetDateTime;\n\nmod common;\n\n#[tokio::test]\nasync fn sync() {\n    let path = format!(\"/{}\", uuid_v7().as_simple());\n    let (address, shutdown, server) = common::start_server(&path).await;\n\n    let client = common::register(&address).await;\n    let hostname = uuid_v7().as_simple().to_string();\n    let now = OffsetDateTime::now_utc();\n\n    let data1 = uuid_v7().as_simple().to_string();\n    let data2 = uuid_v7().as_simple().to_string();\n\n    client\n        .post_history(&[\n            AddHistoryRequest {\n                id: uuid_v7().as_simple().to_string(),\n                timestamp: now,\n                data: data1.clone(),\n                hostname: hostname.clone(),\n            },\n            AddHistoryRequest {\n                id: uuid_v7().as_simple().to_string(),\n                timestamp: now,\n                data: data2.clone(),\n                hostname: hostname.clone(),\n            },\n        ])\n        .await\n        .unwrap();\n\n    let history = client\n        .get_history(OffsetDateTime::UNIX_EPOCH, OffsetDateTime::UNIX_EPOCH, None)\n        .await\n        .unwrap();\n\n    assert_eq!(history.history, vec![data1, data2]);\n\n    shutdown.send(()).unwrap();\n    server.await.unwrap();\n}\n"
  },
  {
    "path": "crates/atuin/tests/users.rs",
    "content": "use atuin_common::utils::uuid_v7;\n\nmod common;\n\n#[tokio::test]\nasync fn registration() {\n    let path = format!(\"/{}\", uuid_v7().as_simple());\n    let (address, shutdown, server) = common::start_server(&path).await;\n    dbg!(&address);\n\n    // -- REGISTRATION --\n\n    let username = uuid_v7().as_simple().to_string();\n    let password = uuid_v7().as_simple().to_string();\n    let client = common::register_inner(&address, &username, &password).await;\n\n    // the session token works\n    let status = client.status().await.unwrap();\n    assert_eq!(status.username, username);\n\n    // -- LOGIN --\n\n    let client = common::login(&address, username.clone(), password).await;\n\n    // the session token works\n    let status = client.status().await.unwrap();\n    assert_eq!(status.username, username);\n\n    shutdown.send(()).unwrap();\n    server.await.unwrap();\n}\n\n#[tokio::test]\nasync fn change_password() {\n    let path = format!(\"/{}\", uuid_v7().as_simple());\n    let (address, shutdown, server) = common::start_server(&path).await;\n\n    // -- REGISTRATION --\n\n    let username = uuid_v7().as_simple().to_string();\n    let password = uuid_v7().as_simple().to_string();\n    let client = common::register_inner(&address, &username, &password).await;\n\n    // the session token works\n    let status = client.status().await.unwrap();\n    assert_eq!(status.username, username);\n\n    // -- PASSWORD CHANGE --\n\n    let current_password = password;\n    let new_password = uuid_v7().as_simple().to_string();\n    let result = client\n        .change_password(current_password, new_password.clone())\n        .await;\n\n    // the password change request succeeded\n    assert!(result.is_ok());\n\n    // -- LOGIN --\n\n    let client = common::login(&address, username.clone(), new_password).await;\n\n    // login with new password yields a working token\n    let status = client.status().await.unwrap();\n    assert_eq!(status.username, username);\n\n    shutdown.send(()).unwrap();\n    server.await.unwrap();\n}\n\n#[tokio::test]\nasync fn multi_user_test() {\n    let path = format!(\"/{}\", uuid_v7().as_simple());\n    let (address, shutdown, server) = common::start_server(&path).await;\n    dbg!(&address);\n\n    // -- REGISTRATION --\n\n    let user_one = uuid_v7().as_simple().to_string();\n    let password_one = uuid_v7().as_simple().to_string();\n    let client_one = common::register_inner(&address, &user_one, &password_one).await;\n\n    // the session token works\n    let status = client_one.status().await.unwrap();\n    assert_eq!(status.username, user_one);\n\n    let user_two = uuid_v7().as_simple().to_string();\n    let password_two = uuid_v7().as_simple().to_string();\n    let client_two = common::register_inner(&address, &user_two, &password_two).await;\n\n    // the session token works\n    let status = client_two.status().await.unwrap();\n    assert_eq!(status.username, user_two);\n\n    // check that we can change user one's password, and _this does not affect user two_\n\n    let current_password = password_one;\n    let new_password = uuid_v7().as_simple().to_string();\n    let result = client_one\n        .change_password(current_password, new_password.clone())\n        .await;\n\n    // the password change request succeeded\n    assert!(result.is_ok());\n\n    // -- LOGIN --\n\n    let client_one = common::login(&address, user_one.clone(), new_password).await;\n    let client_two = common::login(&address, user_two.clone(), password_two).await;\n\n    // login with new password yields a working token\n    let status = client_one.status().await.unwrap();\n    assert_eq!(status.username, user_one);\n    assert_ne!(status.username, user_two);\n\n    let status = client_two.status().await.unwrap();\n    assert_eq!(status.username, user_two);\n\n    shutdown.send(()).unwrap();\n    server.await.unwrap();\n}\n"
  },
  {
    "path": "crates/atuin-ai/Cargo.toml",
    "content": "[package]\nname = \"atuin-ai\"\nedition = \"2024\"\ndescription = \"AI integration for Atuin CLI\"\n\nrust-version = { workspace = true }\nversion = { workspace = true }\nauthors = { workspace = true }\nlicense = { workspace = true }\nhomepage = { workspace = true }\nrepository = { workspace = true }\n\n# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html\n\n[dependencies]\natuin-client = { workspace = true }\natuin-common = { workspace = true }\ntokio = { workspace = true }\neyre = { workspace = true }\nclap = { workspace = true, features = [\"derive\", \"env\"] }\ntracing = { workspace = true }\ntracing-subscriber = { workspace = true, features = [\n  \"ansi\",\n  \"fmt\",\n  \"registry\",\n  \"env-filter\",\n] }\ndirectories = { workspace = true }\ntracing-appender = \"0.2.4\"\nreqwest = { workspace = true }\nserde = { workspace = true }\nserde_json = { workspace = true }\ncrossterm = { workspace = true, features = [\"use-dev-tty\", \"event-stream\"] }\nratatui = { workspace = true, features = [\"unstable-rendered-line-info\"] }\nfutures = \"0.3\"\neventsource-stream = \"0.2\"\npulldown-cmark = \"0.13.0\"\nasync-stream = \"0.3\"\nuuid = { workspace = true }\ntui-textarea-2 = \"0.9.1\"\nunicode-width = \"0.2\"\n\n[dev-dependencies]\npretty_assertions = { workspace = true }\n"
  },
  {
    "path": "crates/atuin-ai/render-tests.sh",
    "content": "#!/bin/bash\n# Render all test cases from test-renders.json\n# Usage: ./render-tests.sh [test_name]\n#   With no args: renders all tests\n#   With arg: renders only matching test (e.g., ./render-tests.sh 05)\n\nset -e\ncd \"$(dirname \"$0\")\"\n\nJSON_FILE=\"test-renders.json\"\nFILTER=\"${1:-}\"\n\n# Build once\ncargo build -p atuin-ai --quiet\n\n# Count tests\nTOTAL=$(jq length \"$JSON_FILE\")\n\nfor i in $(seq 0 $((TOTAL - 1))); do\n    NAME=$(jq -r \".[$i].name\" \"$JSON_FILE\")\n    DESC=$(jq -r \".[$i].description\" \"$JSON_FILE\")\n    STATE=$(jq -c \".[$i].state\" \"$JSON_FILE\")\n\n    # Skip if filter provided and doesn't match\n    if [[ -n \"$FILTER\" && ! \"$NAME\" =~ $FILTER ]]; then\n        continue\n    fi\n\n    echo \"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\"\n    echo \"[$NAME] $DESC\"\n    echo \"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\"\n    echo \"$STATE\" | cargo run -p atuin-ai --quiet -- debug-render -f plain\n    echo \"\"\ndone\n"
  },
  {
    "path": "crates/atuin-ai/replay-states.sh",
    "content": "#!/bin/bash\n# Replay state snapshots from a debug state JSONL file\n# Usage: ./replay-states.sh <state-file.jsonl> [entry-number]\n#   With no entry: renders all frames in sequence (press Enter to advance)\n#   With entry number: renders just that frame\n\nset -e\n# cd \"$(dirname \"$0\")\"\n\nSTATE_FILE=\"${1:-}\"\nENTRY_FILTER=\"${2:-}\"\n\nif [[ -z \"$STATE_FILE\" ]]; then\n    echo \"Usage: $0 <state-file.jsonl> [entry-number]\"\n    echo \"\"\n    echo \"Examples:\"\n    echo \"  $0 /tmp/state.jsonl          # Interactive replay of all frames\"\n    echo \"  $0 /tmp/state.jsonl 15       # Show just entry 15\"\n    exit 1\nfi\n\nif [[ ! -f \"$STATE_FILE\" ]]; then\n    echo \"Error: File not found: $STATE_FILE\"\n    exit 1\nfi\n\n# Build once\ncargo build -p atuin --quiet\n\n# Count entries\nTOTAL=$(wc -l < \"$STATE_FILE\" | tr -d ' ')\n\nif [[ -n \"$ENTRY_FILTER\" ]]; then\n    # Show single entry\n    LINE=$(sed -n \"${ENTRY_FILTER}p\" \"$STATE_FILE\")\n    if [[ -z \"$LINE\" ]]; then\n        echo \"Error: Entry $ENTRY_FILTER not found (file has $TOTAL entries)\"\n        exit 1\n    fi\n\n    ENTRY=$(echo \"$LINE\" | jq -r '.entry')\n    LABEL=$(echo \"$LINE\" | jq -r '.label')\n    STATE=$(echo \"$LINE\" | jq -c '.state')\n\n    echo \"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\"\n    echo \"[$ENTRY/$TOTAL] $LABEL\"\n    echo \"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\"\n    echo \"$STATE\" | cargo run -p atuin --quiet -- ai debug-render -f ansi\nelse\n    # Interactive replay\n    echo \"Replaying $TOTAL frames from $STATE_FILE\"\n    echo \"Press Enter to advance, 'q' to quit, or number+Enter to jump\"\n    echo \"\"\n\n    CURRENT=1\n    while [[ $CURRENT -le $TOTAL ]]; do\n        LINE=$(sed -n \"${CURRENT}p\" \"$STATE_FILE\")\n        ENTRY=$(echo \"$LINE\" | jq -r '.entry')\n        LABEL=$(echo \"$LINE\" | jq -r '.label')\n        STATE=$(echo \"$LINE\" | jq -c '.state')\n\n        clear\n        echo \"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\"\n        echo \"[$CURRENT/$TOTAL] $LABEL\"\n        echo \"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\"\n        echo \"$STATE\" | cargo run -p atuin --quiet -- ai debug-render -f ansi\n        echo \"\"\n        echo \"[Enter: next] [p: prev] [number: jump] [s: show state JSON] [q: quit]\"\n\n        read -r INPUT\n        case \"$INPUT\" in\n            q|Q)\n                break\n                ;;\n            p|P)\n                if [[ $CURRENT -gt 1 ]]; then\n                    CURRENT=$((CURRENT - 1))\n                fi\n                ;;\n            s|S)\n                echo \"\"\n                echo \"State JSON:\"\n                echo \"$STATE\" | jq .\n                echo \"\"\n                echo \"Press Enter to continue...\"\n                read -r\n                ;;\n            ''|' ')\n                CURRENT=$((CURRENT + 1))\n                ;;\n            *[0-9]*)\n                if [[ \"$INPUT\" =~ ^[0-9]+$ ]] && [[ \"$INPUT\" -ge 1 ]] && [[ \"$INPUT\" -le $TOTAL ]]; then\n                    CURRENT=$INPUT\n                else\n                    echo \"Invalid entry number (1-$TOTAL)\"\n                    sleep 1\n                fi\n                ;;\n        esac\n    done\nfi\n"
  },
  {
    "path": "crates/atuin-ai/src/commands/debug_render.rs",
    "content": "//! Debug render command for TUI development\n//!\n//! Takes JSON state as input and outputs a single rendered frame as text.\n//! Useful for debugging view model derivation and rendering without running the full TUI.\n\nuse eyre::{Context, Result};\nuse ratatui::{Terminal, backend::TestBackend};\nuse serde::Deserialize;\nuse std::io::{self, Read};\nuse std::time::Instant;\n\nuse crate::tui::{\n    render::{RenderContext, render},\n    state::{AppMode, AppState, ConversationEvent, StreamingStatus},\n    view_model::Blocks,\n};\n\n/// JSON input format for debug rendering\n#[derive(Debug, Deserialize)]\npub struct DebugInput {\n    /// Conversation events in API format\n    pub events: Vec<EventInput>,\n    /// Current mode: \"Input\", \"Generating\", \"Streaming\", \"Review\", \"Error\"\n    #[serde(default = \"default_mode\")]\n    pub mode: String,\n    /// Text being streamed (for Streaming mode)\n    #[serde(default)]\n    pub streaming_text: String,\n    /// Current input buffer\n    #[serde(default)]\n    pub input: String,\n    /// Cursor position\n    #[serde(default)]\n    pub cursor_pos: usize,\n    /// Spinner frame (0-3)\n    #[serde(default)]\n    pub spinner_frame: usize,\n    /// Error message\n    #[serde(default)]\n    pub error: Option<String>,\n    /// Session ID from server\n    #[serde(default)]\n    pub session_id: Option<String>,\n    /// Streaming status\n    #[serde(default)]\n    pub streaming_status: Option<String>,\n    /// Whether current turn was interrupted\n    #[serde(default)]\n    pub was_interrupted: bool,\n    /// Terminal width for rendering\n    #[serde(default = \"default_width\")]\n    pub width: u16,\n    /// Terminal height for rendering\n    #[serde(default = \"default_height\")]\n    pub height: u16,\n}\n\nfn default_mode() -> String {\n    \"Review\".to_string()\n}\n\nfn default_width() -> u16 {\n    80\n}\n\nfn default_height() -> u16 {\n    // Default to a reasonable height; state files include calculated height\n    50\n}\n\n/// Event input matching the API protocol format\n#[derive(Debug, Clone, Deserialize)]\n#[serde(tag = \"type\", rename_all = \"snake_case\")]\npub enum EventInput {\n    UserMessage {\n        content: String,\n    },\n    Text {\n        content: String,\n    },\n    ToolCall {\n        id: String,\n        name: String,\n        input: serde_json::Value,\n    },\n    ToolResult {\n        tool_use_id: String,\n        content: String,\n        #[serde(default)]\n        is_error: bool,\n    },\n}\n\nimpl From<EventInput> for ConversationEvent {\n    fn from(input: EventInput) -> Self {\n        match input {\n            EventInput::UserMessage { content } => ConversationEvent::UserMessage { content },\n            EventInput::Text { content } => ConversationEvent::Text { content },\n            EventInput::ToolCall { id, name, input } => {\n                ConversationEvent::ToolCall { id, name, input }\n            }\n            EventInput::ToolResult {\n                tool_use_id,\n                content,\n                is_error,\n            } => ConversationEvent::ToolResult {\n                tool_use_id,\n                content,\n                is_error,\n            },\n        }\n    }\n}\n\nimpl DebugInput {\n    /// Parse JSON from string\n    pub fn from_json(json: &str) -> Result<Self> {\n        serde_json::from_str(json).context(\"Failed to parse debug input JSON\")\n    }\n\n    /// Convert to AppState\n    pub fn to_state(&self) -> AppState {\n        let mode = match self.mode.as_str() {\n            \"Input\" => AppMode::Input,\n            \"Generating\" => AppMode::Generating,\n            \"Streaming\" => AppMode::Streaming,\n            \"Review\" => AppMode::Review,\n            \"Error\" => AppMode::Error,\n            _ => AppMode::Review,\n        };\n\n        let events: Vec<ConversationEvent> = self.events.iter().cloned().map(Into::into).collect();\n\n        let streaming_status = self\n            .streaming_status\n            .as_ref()\n            .map(|s| StreamingStatus::from_status_str(s));\n\n        // Create textarea from input and set cursor position\n        let mut textarea = tui_textarea::TextArea::from(self.input.lines());\n        // Disable underline on cursor line\n        textarea.set_cursor_line_style(ratatui::style::Style::default());\n        // Enable word wrapping\n        textarea.set_wrap_mode(tui_textarea::WrapMode::Word);\n        // Note: cursor_pos from old format is character-based; new format has row/col\n        // For compatibility, just move to end if we have text\n        if !self.input.is_empty() {\n            textarea.move_cursor(tui_textarea::CursorMove::End);\n        }\n\n        AppState {\n            mode,\n            events,\n            streaming_text: self.streaming_text.clone(),\n            textarea,\n            error: self.error.clone(),\n            should_exit: false,\n            exit_action: None,\n            session_id: self.session_id.clone(),\n            streaming_status,\n            was_interrupted: self.was_interrupted,\n            spinner_frame: self.spinner_frame,\n            last_spinner_tick: Instant::now(),\n            streaming_started: None,\n            confirmation_pending: false,\n        }\n    }\n}\n\n/// Output format options\n#[derive(Debug, Clone, Copy, Default)]\npub enum OutputFormat {\n    /// Raw terminal output (ANSI)\n    #[default]\n    Ansi,\n    /// Plain text (strips ANSI codes)\n    Plain,\n    /// JSON with blocks structure\n    Json,\n}\n\n/// Run the debug render command\npub async fn run(input_file: Option<String>, format: OutputFormat) -> Result<()> {\n    // Read input JSON\n    let json = if let Some(path) = input_file {\n        std::fs::read_to_string(&path).context(format!(\"Failed to read input file: {}\", path))?\n    } else {\n        let mut buffer = String::new();\n        io::stdin()\n            .read_to_string(&mut buffer)\n            .context(\"Failed to read from stdin\")?;\n        buffer\n    };\n\n    let debug_input = DebugInput::from_json(&json)?;\n    let state = debug_input.to_state();\n\n    match format {\n        OutputFormat::Json => {\n            // Output the derived blocks as JSON\n            let blocks = Blocks::from_state(&state);\n            println!(\n                \"{}\",\n                serde_json::to_string_pretty(&blocks_to_json(&blocks))?\n            );\n        }\n        OutputFormat::Plain | OutputFormat::Ansi => {\n            // Render to a test backend\n            let backend = TestBackend::new(debug_input.width, debug_input.height);\n            let mut terminal = Terminal::new(backend)?;\n\n            // Load default theme\n            let settings = atuin_client::settings::Settings::new()?;\n            let mut theme_manager = atuin_client::theme::ThemeManager::new(None, None);\n            let theme = theme_manager.load_theme(&settings.theme.name, None);\n\n            let ctx = RenderContext {\n                theme,\n                anchor_col: 0,\n                textarea: Some(&state.textarea),\n                max_height: debug_input.height,\n                popup_mode: false,\n                render_above: false,\n            };\n\n            terminal.draw(|frame| {\n                render(frame, &state, &ctx);\n            })?;\n\n            // Get buffer content\n            let buffer = terminal.backend().buffer();\n            let output = buffer_to_string(buffer, matches!(format, OutputFormat::Plain));\n            print!(\"{}\", output);\n        }\n    }\n\n    Ok(())\n}\n\n/// Convert blocks to JSON for debugging\nfn blocks_to_json(blocks: &Blocks) -> serde_json::Value {\n    serde_json::json!({\n        \"count\": blocks.items.len(),\n        \"blocks\": blocks.items.iter().map(|block| {\n            serde_json::json!({\n                \"separator_above\": block.separator_above,\n                \"title\": block.title,\n                \"content\": block.content.iter().map(content_to_json).collect::<Vec<_>>()\n            })\n        }).collect::<Vec<_>>(),\n        \"status_bar\": blocks.status_bar.as_ref().map(|sb| serde_json::json!({\n            \"frame\": sb.frame,\n            \"text\": sb.text\n        }))\n    })\n}\n\nfn content_to_json(content: &crate::tui::view_model::Content) -> serde_json::Value {\n    use crate::tui::view_model::Content;\n    match content {\n        Content::Input {\n            text,\n            active,\n            cursor_pos,\n        } => serde_json::json!({\n            \"type\": \"Input\",\n            \"text\": text,\n            \"active\": active,\n            \"cursor_pos\": cursor_pos\n        }),\n        Content::Command { text, faded } => serde_json::json!({\n            \"type\": \"Command\",\n            \"text\": text,\n            \"faded\": faded\n        }),\n        Content::Text { markdown } => serde_json::json!({\n            \"type\": \"Text\",\n            \"markdown\": markdown\n        }),\n        Content::Error { message } => serde_json::json!({\n            \"type\": \"Error\",\n            \"message\": message\n        }),\n        Content::Warning {\n            kind,\n            text,\n            pending_confirm,\n        } => serde_json::json!({\n            \"type\": \"Warning\",\n            \"kind\": format!(\"{:?}\", kind),\n            \"text\": text,\n            \"pending_confirm\": pending_confirm\n        }),\n        Content::Spinner { frame, status_text } => serde_json::json!({\n            \"type\": \"Spinner\",\n            \"frame\": frame,\n            \"status_text\": status_text\n        }),\n        Content::ToolStatus {\n            completed_count,\n            current_label,\n            frame,\n        } => serde_json::json!({\n            \"type\": \"ToolStatus\",\n            \"completed_count\": completed_count,\n            \"current_label\": current_label,\n            \"frame\": frame\n        }),\n    }\n}\n\n/// Convert ratatui buffer to string\nfn buffer_to_string(buffer: &ratatui::buffer::Buffer, strip_ansi: bool) -> String {\n    let area = buffer.area;\n    let mut output = String::new();\n\n    for y in 0..area.height {\n        for x in 0..area.width {\n            let cell = &buffer[(x, y)];\n            if strip_ansi {\n                output.push_str(cell.symbol());\n            } else {\n                // Include ANSI styling\n                let fg = cell.fg;\n                let bg = cell.bg;\n                let mods = cell.modifier;\n\n                // Simple ANSI encoding\n                if fg != ratatui::style::Color::Reset\n                    || bg != ratatui::style::Color::Reset\n                    || !mods.is_empty()\n                {\n                    output.push_str(\"\\x1b[\");\n                    let mut first = true;\n\n                    if mods.contains(ratatui::style::Modifier::BOLD) {\n                        output.push('1');\n                        first = false;\n                    }\n                    if mods.contains(ratatui::style::Modifier::DIM) {\n                        if !first {\n                            output.push(';');\n                        }\n                        output.push('2');\n                        first = false;\n                    }\n                    if mods.contains(ratatui::style::Modifier::REVERSED) {\n                        if !first {\n                            output.push(';');\n                        }\n                        output.push('7');\n                        first = false;\n                    }\n                    if mods.contains(ratatui::style::Modifier::UNDERLINED) {\n                        if !first {\n                            output.push(';');\n                        }\n                        output.push('4');\n                        first = false;\n                    }\n\n                    if let Some(code) = color_to_ansi(fg, true) {\n                        if !first {\n                            output.push(';');\n                        }\n                        output.push_str(&code);\n                        first = false;\n                    }\n\n                    if let Some(code) = color_to_ansi(bg, false) {\n                        if !first {\n                            output.push(';');\n                        }\n                        output.push_str(&code);\n                    }\n\n                    output.push('m');\n                }\n\n                output.push_str(cell.symbol());\n\n                if fg != ratatui::style::Color::Reset\n                    || bg != ratatui::style::Color::Reset\n                    || !mods.is_empty()\n                {\n                    output.push_str(\"\\x1b[0m\");\n                }\n            }\n        }\n        output.push('\\n');\n    }\n\n    output\n}\n\nfn color_to_ansi(color: ratatui::style::Color, foreground: bool) -> Option<String> {\n    use ratatui::style::Color;\n    let base = if foreground { 30 } else { 40 };\n\n    match color {\n        Color::Reset => None,\n        Color::Black => Some((base).to_string()),\n        Color::Red => Some((base + 1).to_string()),\n        Color::Green => Some((base + 2).to_string()),\n        Color::Yellow => Some((base + 3).to_string()),\n        Color::Blue => Some((base + 4).to_string()),\n        Color::Magenta => Some((base + 5).to_string()),\n        Color::Cyan => Some((base + 6).to_string()),\n        Color::Gray | Color::White => Some((base + 7).to_string()),\n        Color::DarkGray => Some((base + 60).to_string()),\n        Color::LightRed => Some((base + 61).to_string()),\n        Color::LightGreen => Some((base + 62).to_string()),\n        Color::LightYellow => Some((base + 63).to_string()),\n        Color::LightBlue => Some((base + 64).to_string()),\n        Color::LightMagenta => Some((base + 65).to_string()),\n        Color::LightCyan => Some((base + 66).to_string()),\n        Color::Indexed(i) => Some(format!(\"{}8;5;{}\", if foreground { 3 } else { 4 }, i)),\n        Color::Rgb(r, g, b) => Some(format!(\n            \"{}8;2;{};{};{}\",\n            if foreground { 3 } else { 4 },\n            r,\n            g,\n            b\n        )),\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn test_parse_simple_input() {\n        let json = r#\"{\n            \"events\": [\n                {\"type\": \"user_message\", \"content\": \"list files\"},\n                {\"type\": \"tool_call\", \"id\": \"123\", \"name\": \"suggest_command\", \"input\": {\"command\": \"ls -la\"}}\n            ],\n            \"mode\": \"Review\"\n        }\"#;\n\n        let input = DebugInput::from_json(json).unwrap();\n        assert_eq!(input.events.len(), 2);\n        assert_eq!(input.mode, \"Review\");\n\n        let state = input.to_state();\n        assert_eq!(state.events.len(), 2);\n        assert_eq!(state.mode, AppMode::Review);\n    }\n\n    #[test]\n    fn test_parse_streaming_state() {\n        let json = r#\"{\n            \"events\": [\n                {\"type\": \"user_message\", \"content\": \"explain flags\"}\n            ],\n            \"mode\": \"Streaming\",\n            \"streaming_text\": \"The -l flag means...\"\n        }\"#;\n\n        let input = DebugInput::from_json(json).unwrap();\n        let state = input.to_state();\n        assert_eq!(state.mode, AppMode::Streaming);\n        assert_eq!(state.streaming_text, \"The -l flag means...\");\n    }\n}\n"
  },
  {
    "path": "crates/atuin-ai/src/commands/init.rs",
    "content": "use crate::commands::detect_shell;\n\npub async fn run(shell: String) -> eyre::Result<()> {\n    let integration = match shell.as_str() {\n        \"zsh\" => generate_zsh_integration(),\n        \"bash\" => generate_bash_integration(),\n        \"fish\" => generate_fish_integration(),\n        \"auto\" => generate_auto_integration()?,\n        _ => eyre::bail!(\"Unsupported shell: {}\", shell),\n    };\n\n    println!(\"{}\", integration);\n    Ok(())\n}\n\nfn generate_auto_integration() -> eyre::Result<&'static str> {\n    let shell = detect_shell();\n    match shell.as_deref() {\n        Some(\"zsh\") => Ok(generate_zsh_integration()),\n        Some(\"bash\") => Ok(generate_bash_integration()),\n        Some(\"fish\") => Ok(generate_fish_integration()),\n        Some(s) => eyre::bail!(\"Unsupported shell: {}\", s),\n        None => eyre::bail!(\"Could not detect shell\"),\n    }\n}\n\n/// Generate the zsh integration function - pure function for easy testing\npub fn generate_zsh_integration() -> &'static str {\n    r#\"\n# TUI uses an alternate screen, so no explicit cleanup is needed.\n_atuin_ai_cleanup() {\n    true\n}\n\n# Question mark at start of line - natural language mode.\n# Named with 'self-' prefix so bracketed-paste-magic activates it during\n# paste, allowing url-quote-magic to escape ? in pasted URLs via self-insert.\nself-atuin-ai-question-mark() {\n    # If buffer is empty or just contains '?', trigger natural language mode\n    if [[ -z \"$BUFFER\" || \"$BUFFER\" == \"?\" ]]; then\n        BUFFER=\"\"\n        local output\n        output=$(atuin ai inline --hook 3>&1 1>&2 2>&3)\n\n        # Clean up the inline viewport\n        _atuin_ai_cleanup\n\n        if [[ $output == __atuin_ai_print__:* ]]; then\n            zle -I\n            echo \"${output#__atuin_ai_print__:}\"\n        elif [[ $output == __atuin_ai_cancel__ ]]; then\n            zle reset-prompt\n        elif [[ $output == __atuin_ai_execute__:* ]]; then\n            RBUFFER=\"\"\n            LBUFFER=${output#__atuin_ai_execute__:}\n            zle reset-prompt\n            zle accept-line\n        elif [[ $output == __atuin_ai_insert__:* ]]; then\n            RBUFFER=\"\"\n            LBUFFER=${output#__atuin_ai_insert__:}\n            zle reset-prompt\n        elif [[ -n $output ]]; then\n            RBUFFER=\"\"\n            LBUFFER=$output\n            zle reset-prompt\n        else\n            zle reset-prompt\n        fi\n    else\n        zle self-insert\n    fi\n}\n\n# Set up keybindings\nzle -N self-atuin-ai-question-mark\nbindkey '?' self-atuin-ai-question-mark # Question mark\n\"#\n    .trim()\n}\n\n/// Generate the bash integration function - pure function for easy testing\npub fn generate_bash_integration() -> &'static str {\n    r#\"\n# Question mark at start of line - natural language mode\n_atuin_ai_question_mark() {\n    # If buffer is empty or just contains '?', trigger natural language mode\n    if [[ -z \"$READLINE_LINE\" || \"$READLINE_LINE\" == \"?\" ]]; then\n        READLINE_LINE=\"\"\n        READLINE_POINT=0\n\n        local output\n        output=$(atuin ai inline --hook 3>&1 1>&2 2>&3)\n\n        if [[ $output == __atuin_ai_print__:* ]]; then\n            echo \"${output#__atuin_ai_print__:}\"\n            READLINE_LINE=\"\"\n            READLINE_POINT=0\n        elif [[ $output == __atuin_ai_cancel__ ]]; then\n            READLINE_LINE=\"\"\n            READLINE_POINT=0\n        elif [[ $output == __atuin_ai_execute__:* ]]; then\n            # Execute the command immediately\n            READLINE_LINE=${output#__atuin_ai_execute__:}\n            READLINE_POINT=${#READLINE_LINE}\n            # Note: We can't directly execute in bash bind -x, but we can\n            # use a workaround by binding to a macro that accepts the line\n            bind '\"\\C-x\\C-a\": accept-line'\n            bind -x '\"\\C-x\\C-e\": _atuin_ai_question_mark'\n        elif [[ $output == __atuin_ai_insert__:* ]]; then\n            # Insert the command for editing\n            READLINE_LINE=${output#__atuin_ai_insert__:}\n            READLINE_POINT=${#READLINE_LINE}\n        elif [[ -n $output ]]; then\n            # Default: insert for editing\n            READLINE_LINE=$output\n            READLINE_POINT=${#READLINE_LINE}\n        fi\n    else\n        # Not at empty prompt, just insert the question mark\n        READLINE_LINE=\"${READLINE_LINE:0:READLINE_POINT}?${READLINE_LINE:READLINE_POINT}\"\n        ((READLINE_POINT++))\n    fi\n}\n\n# Set up keybindings\n# Bash requires special handling: we use bind -x for the function,\n# but need a two-step approach for execute mode\n__atuin_ai_accept_line=\"\"\n\n_atuin_ai_question_mark_wrapper() {\n    _atuin_ai_question_mark\n    if [[ -n \"$__atuin_ai_accept_line\" ]]; then\n        __atuin_ai_accept_line=\"\"\n    fi\n}\n\nbind -x '\"?\": _atuin_ai_question_mark'\n\"#\n    .trim()\n}\n\n/// Generate the fish integration function - pure function for easy testing\npub fn generate_fish_integration() -> &'static str {\n    r#\"\n# Question mark at start of line - natural language mode\nfunction _atuin_ai_question_mark\n    set -l buf (commandline -b)\n\n    # If buffer is empty or just contains '?', trigger natural language mode\n    if test -z \"$buf\" -o \"$buf\" = \"?\"\n        commandline -r \"\"\n\n        # Run atuin ai inline, swapping stdout and stderr\n        set -l output (atuin ai inline --hook 3>&1 1>&2 2>&3 | string collect)\n\n        if string match --quiet '__atuin_ai_print__:*' \"$output\"\n            echo (string replace \"__atuin_ai_print__:\" \"\" -- \"$output\" | string collect)\n            commandline -f repaint\n        else if test \"$output\" = \"__atuin_ai_cancel__\"\n            commandline -f repaint\n        else if string match --quiet '__atuin_ai_execute__:*' \"$output\"\n            # Execute the command immediately\n            set -l cmd (string replace \"__atuin_ai_execute__:\" \"\" -- \"$output\" | string collect)\n            commandline -r \"$cmd\"\n            commandline -f repaint\n            commandline -f execute\n        else if string match --quiet '__atuin_ai_insert__:*' \"$output\"\n            # Insert the command for editing\n            set -l cmd (string replace \"__atuin_ai_insert__:\" \"\" -- \"$output\" | string collect)\n            commandline -r \"$cmd\"\n            commandline -f repaint\n        else if test -n \"$output\"\n            # Default: insert for editing\n            commandline -r \"$output\"\n            commandline -f repaint\n        else\n            commandline -f repaint\n        end\n    else\n        # Not at empty prompt, just insert the question mark\n        commandline -i \"?\"\n    end\nend\n\n# Set up keybindings\nbind \"?\" _atuin_ai_question_mark\n\"#\n    .trim()\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn test_generate_zsh_integration() {\n        let result = generate_zsh_integration();\n        assert!(result.contains(\"self-atuin-ai-question-mark\"));\n        assert!(result.contains(\"bindkey\"));\n        assert!(result.contains(\"atuin ai inline --hook\"));\n        assert!(result.contains(\"__atuin_ai_print__\"));\n        assert!(result.contains(\"__atuin_ai_cancel__\"));\n        assert!(result.contains(\"__atuin_ai_execute__\"));\n        assert!(result.contains(\"__atuin_ai_insert__\"));\n        assert!(result.contains(\"zle self-insert\"));\n    }\n\n    #[test]\n    fn test_generate_bash_integration() {\n        let result = generate_bash_integration();\n        assert!(result.contains(\"_atuin_ai_question_mark\"));\n        assert!(result.contains(\"bind\"));\n        assert!(result.contains(\"READLINE_LINE\"));\n        assert!(result.contains(\"atuin ai inline --hook\"));\n        assert!(result.contains(\"__atuin_ai_print__\"));\n        assert!(result.contains(\"__atuin_ai_cancel__\"));\n        assert!(result.contains(\"__atuin_ai_execute__\"));\n        assert!(result.contains(\"__atuin_ai_insert__\"));\n    }\n\n    #[test]\n    fn test_generate_fish_integration() {\n        let result = generate_fish_integration();\n        assert!(result.contains(\"_atuin_ai_question_mark\"));\n        assert!(result.contains(\"bind\"));\n        assert!(result.contains(\"commandline\"));\n        assert!(result.contains(\"atuin ai inline --hook\"));\n        assert!(result.contains(\"__atuin_ai_print__\"));\n        assert!(result.contains(\"__atuin_ai_cancel__\"));\n        assert!(result.contains(\"__atuin_ai_execute__\"));\n        assert!(result.contains(\"__atuin_ai_insert__\"));\n    }\n}\n"
  },
  {
    "path": "crates/atuin-ai/src/commands/inline.rs",
    "content": "use crate::commands::detect_shell;\nuse crate::tui::render::render;\nuse crate::tui::{\n    App, AppEvent, AppMode, ConversationEvent, EventLoop, ExitAction, RenderContext, TerminalGuard,\n    calculate_needed_height, install_panic_hook,\n};\nuse atuin_client::distro::detect_linux_distribution;\nuse atuin_client::theme::ThemeManager;\nuse atuin_common::tls::ensure_crypto_provider;\nuse crossterm::{\n    event::{self, Event, KeyCode},\n    terminal::{disable_raw_mode, enable_raw_mode},\n};\nuse eventsource_stream::Eventsource;\nuse eyre::{Context as _, Result, bail};\nuse futures::StreamExt;\nuse reqwest::Url;\nuse std::io::Write;\nuse tracing::{debug, error, info, trace};\n\npub async fn run(\n    initial_command: Option<String>,\n    api_endpoint: Option<String>,\n    api_token: Option<String>,\n    keep_output: bool,\n    debug_state_file: Option<String>,\n    settings: &atuin_client::settings::Settings,\n    output_for_hook: bool,\n) -> Result<()> {\n    if !settings.ai.enabled {\n        emit_shell_result(\n            Action::Print(\n                \"Atuin AI is not enabled. Please enable it in your settings or run `atuin setup`.\"\n                    .to_string(),\n            ),\n            output_for_hook,\n        );\n        return Ok(());\n    }\n\n    // Install panic hook once at entry point to ensure terminal restoration\n    install_panic_hook();\n\n    // Token and endpoint priority:\n    // 1. Command line arguments/environment variables\n    // 2. Settings file\n    // 3. Default\n    let endpoint = api_endpoint.as_deref().unwrap_or(\n        settings\n            .ai\n            .endpoint\n            .as_deref()\n            .unwrap_or(\"https://hub.atuin.sh\"),\n    );\n    let api_token = api_token.as_deref().or(settings.ai.api_token.as_deref());\n\n    let token = if let Some(token) = &api_token {\n        token.to_string()\n    } else {\n        // ensure_hub_session will authenticate against settings.active_hub_endpoint().unwrap_or_default(),\n        // which is the default Hub endpoint if no endpoint is provided\n        //\n        // TODO[mkt]: Atuin AI and the Hub sync endpoint are too tightly coupled;\n        // current setup means that Hub endpoint controls auth while AI endpoint controls AI conversations\n        ensure_hub_session(settings).await?\n    };\n\n    let action = run_inline_tui(\n        endpoint.to_string(),\n        token,\n        initial_command,\n        keep_output,\n        debug_state_file,\n        settings,\n    )\n    .await?;\n    emit_shell_result(action, output_for_hook);\n\n    Ok(())\n}\n\nasync fn ensure_hub_session(settings: &atuin_client::settings::Settings) -> Result<String> {\n    if let Some(token) = atuin_client::hub::get_session_token().await? {\n        debug!(\"Found Hub session, using existing token\");\n        return Ok(token);\n    }\n\n    let hub_address = settings.active_hub_endpoint().unwrap_or_default();\n\n    let will_sync = settings.is_hub_sync();\n\n    info!(\"No Hub session found, prompting for authentication\");\n\n    println!(\"Atuin AI requires authenticating with Atuin Hub.\");\n    if will_sync {\n        println!(\n            \"Once logged in, your shell history will be synchronized via Atuin Hub if auto_sync is enabled or when manually syncing.\"\n        )\n    }\n    println!(\n        \"If you have an existing Atuin sync account, you can log in with your existing credentials.\"\n    );\n    println!(\"Press enter to begin (or esc to cancel).\");\n    if !wait_for_login_confirmation()? {\n        bail!(\"authentication canceled\");\n    }\n\n    debug!(\"Starting Atuin Hub authentication...\");\n\n    println!(\"Authenticating with Atuin Hub...\");\n    let session = atuin_client::hub::HubAuthSession::start(hub_address.as_ref()).await?;\n    println!(\"Open this URL to continue:\");\n    println!(\"{}\", session.auth_url);\n\n    let token = session\n        .wait_for_completion(\n            atuin_client::hub::DEFAULT_AUTH_TIMEOUT,\n            atuin_client::hub::DEFAULT_POLL_INTERVAL,\n        )\n        .await?;\n\n    info!(\"Authentication complete, saving session token\");\n\n    atuin_client::hub::save_session(&token).await?;\n\n    // Silently attempt to link CLI account to Hub if one exists\n    // This enables unified auth - users can use their Hub token for sync\n    if let Ok(meta) = atuin_client::settings::Settings::meta_store().await\n        && let Ok(Some(cli_token)) = meta.session_token().await\n    {\n        debug!(\"CLI session found, attempting to link accounts\");\n        if let Err(e) = atuin_client::hub::link_account(hub_address.as_ref(), &cli_token).await {\n            // Don't fail AI flow if linking fails - it's not critical\n            debug!(\"Could not link CLI account to Hub: {}\", e);\n        } else {\n            info!(\"Successfully linked CLI account to Hub\");\n        }\n    }\n\n    Ok(token)\n}\n\n/// SSE event received from chat endpoint\n#[derive(Debug, Clone)]\nenum ChatStreamEvent {\n    /// Text chunk to display\n    TextChunk(String),\n    /// Tool call event (need to echo back, may contain suggest_command)\n    ToolCall {\n        id: String,\n        name: String,\n        input: serde_json::Value,\n    },\n    /// Tool result from server-side execution\n    ToolResult {\n        tool_use_id: String,\n        content: String,\n        is_error: bool,\n    },\n    /// Status update from server\n    Status(String),\n    /// Stream complete\n    Done { session_id: String },\n    /// Error from server\n    Error(String),\n}\n\nfn create_chat_stream(\n    hub_address: String,\n    token: String,\n    session_id: Option<String>,\n    messages: Vec<serde_json::Value>,\n    settings: &atuin_client::settings::Settings,\n) -> std::pin::Pin<Box<dyn futures::Stream<Item = Result<ChatStreamEvent>> + Send>> {\n    let send_cwd = settings.ai.send_cwd;\n\n    Box::pin(async_stream::stream! {\n        ensure_crypto_provider();\n        let endpoint = match hub_url(&hub_address, \"/api/cli/chat\") {\n            Ok(url) => url,\n            Err(e) => {\n                yield Err(e);\n                return;\n            }\n        };\n\n        debug!(\"Sending SSE request to {endpoint}\");\n\n        let os = detect_os();\n        let shell = detect_shell();\n\n        let mut context = serde_json::json!({\n            \"os\": os,\n            \"shell\": shell,\n            \"pwd\": if send_cwd { std::env::current_dir()\n                .ok()\n                .map(|path| path.to_string_lossy().into_owned()) } else { None },\n        });\n\n        if os == \"linux\" {\n            context[\"distro\"] = serde_json::json!(detect_linux_distribution());\n        }\n\n        // Build request body\n        let mut request_body = serde_json::json!({\n            \"messages\": messages,\n            \"context\": context,\n        });\n\n        // Include session_id only if present (not on first request)\n        if let Some(ref sid) = session_id {\n            trace!(\"Including session_id in request: {sid}\");\n            request_body[\"session_id\"] = serde_json::json!(sid);\n        }\n\n\n        let client = reqwest::Client::new();\n        let response = match client\n            .post(endpoint.clone())\n            .header(\"Accept\", \"text/event-stream\")\n            .bearer_auth(&token)\n            .json(&request_body)\n            .send()\n            .await\n        {\n            Ok(resp) => resp,\n            Err(e) => {\n                yield Err(eyre::eyre!(\"Failed to send SSE request: {}\", e));\n                return;\n            }\n        };\n\n        let status = response.status();\n        if status == reqwest::StatusCode::UNAUTHORIZED {\n            // Clear saved session on auth error\n            error!(\"SSE request failed with status: {status}, clearing session\");\n            let _ = atuin_client::hub::delete_session().await;\n            yield Err(eyre::eyre!(\"Hub session expired. Re-run to authenticate again.\"));\n            return;\n        }\n        if !status.is_success() {\n            let body = response.text().await.unwrap_or_default();\n            error!(\"SSE request failed ({}): {}\", status, body);\n            yield Err(eyre::eyre!(\"SSE request failed ({}): {}\", status, body));\n            return;\n        }\n\n        let byte_stream = response.bytes_stream();\n        let mut stream = byte_stream.eventsource();\n\n        while let Some(event) = stream.next().await {\n            match event {\n                Ok(sse_event) => {\n                    let event_type = sse_event.event.as_str();\n                    let data = sse_event.data.clone();\n\n                    debug!(event_type = %event_type, \"SSE event received\");\n\n                    match event_type {\n                        \"text\" => {\n                            if let Ok(json) = serde_json::from_str::<serde_json::Value>(&data)\n                                && let Some(content) = json.get(\"content\").and_then(|v| v.as_str())\n                            {\n                                yield Ok(ChatStreamEvent::TextChunk(content.to_string()));\n                            }\n                        }\n                        \"tool_call\" => {\n                            if let Ok(json) = serde_json::from_str::<serde_json::Value>(&data) {\n                                let id = json.get(\"id\").and_then(|v| v.as_str()).unwrap_or(\"\").to_string();\n                                let name = json.get(\"name\").and_then(|v| v.as_str()).unwrap_or(\"\").to_string();\n                                let input = json.get(\"input\").cloned().unwrap_or(serde_json::json!({}));\n                                yield Ok(ChatStreamEvent::ToolCall { id, name, input });\n                            }\n                        }\n                        \"tool_result\" => {\n                            if let Ok(json) = serde_json::from_str::<serde_json::Value>(&data) {\n                                let tool_use_id = json.get(\"tool_use_id\").and_then(|v| v.as_str()).unwrap_or(\"\").to_string();\n                                let content = json.get(\"content\").and_then(|v| v.as_str()).unwrap_or(\"\").to_string();\n                                let is_error = json.get(\"is_error\").and_then(|v| v.as_bool()).unwrap_or(false);\n                                yield Ok(ChatStreamEvent::ToolResult { tool_use_id, content, is_error });\n                            }\n                        }\n                        \"status\" => {\n                            if let Ok(json) = serde_json::from_str::<serde_json::Value>(&data)\n                                && let Some(state) = json.get(\"state\").and_then(|v| v.as_str())\n                            {\n                                yield Ok(ChatStreamEvent::Status(state.to_string()));\n                            }\n                        }\n                        \"done\" => {\n                            if let Ok(json) = serde_json::from_str::<serde_json::Value>(&data) {\n                                let session_id = json.get(\"session_id\")\n                                    .and_then(|v| v.as_str())\n                                    .unwrap_or(\"\")\n                                    .to_string();\n                                yield Ok(ChatStreamEvent::Done { session_id });\n                            } else {\n                                yield Ok(ChatStreamEvent::Done { session_id: String::new() });\n                            }\n                            break;\n                        }\n                        \"error\" => {\n                            if let Ok(json) = serde_json::from_str::<serde_json::Value>(&data) {\n                                let message = json.get(\"message\").and_then(|v| v.as_str()).unwrap_or(\"Unknown error\").to_string();\n                                error!(\"SSE error: {}\", message);\n                                yield Ok(ChatStreamEvent::Error(message));\n                            } else {\n                                error!(\"SSE error: {}\", data);\n                                yield Ok(ChatStreamEvent::Error(data));\n                            }\n                            break;\n                        }\n                        _ => {\n                            // Unknown event type, ignore\n                        }\n                    }\n                }\n                Err(e) => {\n                    yield Err(eyre::eyre!(\"SSE error: {}\", e));\n                    break;\n                }\n            }\n        }\n    })\n}\n\nfn hub_url(base: &str, path: &str) -> Result<Url> {\n    let base_with_slash = if base.ends_with('/') {\n        base.to_string()\n    } else {\n        format!(\"{base}/\")\n    };\n    let stripped = path.strip_prefix('/').unwrap_or(path);\n    Url::parse(&base_with_slash)?\n        .join(stripped)\n        .context(\"failed to build hub URL\")\n}\n\nfn detect_os() -> String {\n    match std::env::consts::OS {\n        \"macos\" => \"macos\".to_string(),\n        \"linux\" => \"linux\".to_string(),\n        \"windows\" => \"windows\".to_string(),\n        other => format!(\"Other: {other}\"),\n    }\n}\n\n#[derive(Clone)]\nenum Action {\n    Execute(String),\n    Insert(String),\n    Print(String),\n    Cancel,\n}\n\n/// Serialize AppState to JSON for debug logging\nfn state_to_json(state: &crate::tui::AppState) -> serde_json::Value {\n    let events: Vec<serde_json::Value> = state.events.iter().map(|e| e.to_json()).collect();\n\n    let mode = match state.mode {\n        AppMode::Input => \"Input\",\n        AppMode::Generating => \"Generating\",\n        AppMode::Streaming => \"Streaming\",\n        AppMode::Review => \"Review\",\n        AppMode::Error => \"Error\",\n    };\n\n    // Get input and cursor from textarea\n    let input = state.input();\n    let cursor = state.textarea.cursor();\n\n    let mut json = serde_json::json!({\n        \"events\": events,\n        \"mode\": mode,\n        \"input\": input,\n        \"cursor_row\": cursor.0,\n        \"cursor_col\": cursor.1,\n        \"spinner_frame\": state.spinner_frame,\n        \"confirmation_pending\": state.confirmation_pending,\n    });\n\n    // Add streaming fields if in streaming mode\n    if !state.streaming_text.is_empty() {\n        json[\"streaming_text\"] = serde_json::json!(state.streaming_text);\n    }\n    if let Some(ref status) = state.streaming_status {\n        json[\"streaming_status\"] = serde_json::json!(status.display_text());\n    }\n    if let Some(ref err) = state.error {\n        json[\"error\"] = serde_json::json!(err);\n    }\n\n    json\n}\n\n/// Debug logger that writes state changes to a file\nstruct DebugStateLogger {\n    file: std::fs::File,\n    entry_count: usize,\n    width: u16,\n}\n\nimpl DebugStateLogger {\n    fn new(path: &str) -> Result<Self> {\n        let file = std::fs::File::create(path)\n            .with_context(|| format!(\"Failed to create debug state file: {}\", path))?;\n        // Get terminal width, default to 80\n        let (width, _) = crossterm::terminal::size().unwrap_or((80, 24));\n        Ok(Self {\n            file,\n            entry_count: 0,\n            width,\n        })\n    }\n\n    fn log(&mut self, label: &str, state: &crate::tui::AppState) {\n        use crate::tui::calculate_needed_height;\n\n        self.entry_count += 1;\n        let timestamp_ms = std::time::SystemTime::now()\n            .duration_since(std::time::UNIX_EPOCH)\n            .map(|d| d.as_millis())\n            .unwrap_or(0);\n\n        // Calculate the actual content height needed for this state\n        let content_height = calculate_needed_height(state, 0);\n\n        let mut state_json = state_to_json(state);\n        // Add dimensions for accurate replay\n        state_json[\"width\"] = serde_json::json!(self.width);\n        state_json[\"height\"] = serde_json::json!(content_height);\n\n        let entry = serde_json::json!({\n            \"entry\": self.entry_count,\n            \"label\": label,\n            \"timestamp_ms\": timestamp_ms,\n            \"state\": state_json,\n        });\n\n        // Write as JSONL (one JSON object per line)\n        if let Err(e) = writeln!(self.file, \"{}\", entry) {\n            tracing::warn!(\"Failed to write debug state: {}\", e);\n        }\n        let _ = self.file.flush();\n    }\n}\n\nasync fn run_inline_tui(\n    endpoint: String,\n    token: String,\n    initial_prompt: Option<String>,\n    keep_output: bool,\n    debug_state_file: Option<String>,\n    settings: &atuin_client::settings::Settings,\n) -> Result<Action> {\n    // Detect popup mode (only on Unix where atuin-hex socket is available)\n    #[cfg(unix)]\n    let mut popup_state = crate::tui::popup::try_setup_popup();\n    #[cfg(not(unix))]\n    let mut popup_state: Option<()> = None;\n\n    let popup_mode = popup_state.is_some();\n\n    // Initialize terminal guard: popup mode uses Fixed viewport, inline uses Inline\n    #[cfg(unix)]\n    let mut guard = if let Some(ref ps) = popup_state {\n        TerminalGuard::new_popup(ps.current_rect, ps.saved_screen.cursor_col)?\n    } else {\n        TerminalGuard::new(keep_output)?\n    };\n    #[cfg(not(unix))]\n    let mut guard = TerminalGuard::new(keep_output)?;\n    let mut app = App::new();\n    if let Some(prompt) = initial_prompt {\n        // Set initial text in textarea\n        let mut textarea = tui_textarea::TextArea::from(prompt.lines());\n        // Disable underline on cursor line\n        textarea.set_cursor_line_style(ratatui::style::Style::default());\n        // Enable word wrapping\n        textarea.set_wrap_mode(tui_textarea::WrapMode::Word);\n        // Move cursor to end\n        textarea.move_cursor(tui_textarea::CursorMove::End);\n        app.state.textarea = textarea;\n    }\n\n    // Initialize debug state logger if requested\n    let mut debug_logger = debug_state_file\n        .map(|path| DebugStateLogger::new(&path))\n        .transpose()?;\n\n    // Helper macro to log state changes\n    macro_rules! log_state {\n        ($label:expr) => {\n            if let Some(ref mut logger) = debug_logger {\n                logger.log($label, &app.state);\n            }\n        };\n    }\n\n    // Log initial state\n    log_state!(\"init\");\n\n    // Load theme\n    let mut theme_manager = ThemeManager::new(None, None);\n    let theme = theme_manager.load_theme(&settings.theme.name, None);\n\n    // Initialize event loop\n    let mut event_loop = EventLoop::new();\n\n    // Track chat stream\n    let mut chat_stream: Option<\n        std::pin::Pin<Box<dyn futures::Stream<Item = Result<ChatStreamEvent>> + Send>>,\n    > = None;\n\n    loop {\n        // Ensure viewport is large enough for current content (capped at terminal height)\n        // In popup mode, use the actual popup width for accurate height calculation\n        let card_width = if popup_mode {\n            #[cfg(unix)]\n            {\n                popup_state\n                    .as_ref()\n                    .map(|ps| {\n                        ps.current_rect\n                            .width\n                            .saturating_sub(crate::tui::popup::POPUP_MARGIN * 2)\n                    })\n                    .unwrap_or(0)\n            }\n            #[cfg(not(unix))]\n            {\n                0\n            }\n        } else {\n            0\n        };\n        let needed_height = calculate_needed_height(&app.state, card_width);\n\n        // Grow popup dynamically as content arrives\n        #[cfg(unix)]\n        if let Some(ref mut ps) = popup_state {\n            // Add vertical margin for visual separation from terminal content\n            let popup_height = needed_height.saturating_add(crate::tui::popup::POPUP_MARGIN * 2);\n            if let Some(new_rect) = ps.fit_to(popup_height) {\n                guard.resize_popup(new_rect)?;\n            }\n        }\n\n        let actual_height = guard.ensure_height(needed_height)?;\n\n        // Render current state\n        let anchor_col = guard.anchor_col();\n        #[cfg(unix)]\n        let render_above = popup_state.as_ref().is_some_and(|ps| ps.render_above);\n        #[cfg(not(unix))]\n        let render_above = false;\n\n        let ctx = RenderContext {\n            theme,\n            anchor_col,\n            textarea: Some(&app.state.textarea),\n            max_height: actual_height,\n            popup_mode,\n            render_above,\n        };\n        // Handle draw errors gracefully - cursor position reads can fail during resize\n        if let Err(e) = guard.terminal().draw(|frame| {\n            render(frame, &app.state, &ctx);\n        }) {\n            let err_msg = e.to_string();\n            if err_msg.contains(\"cursor position\") {\n                // Cursor position read failed (common during terminal resize)\n                // Skip this frame and continue - next frame will likely succeed\n                tracing::debug!(\n                    \"Skipping frame due to cursor position read error: {}\",\n                    err_msg\n                );\n                continue;\n            }\n            return Err(e.into());\n        }\n\n        // Get next event\n        let event = event_loop.run().await?;\n\n        // Handle event based on app mode\n        match event {\n            AppEvent::Key(key) => {\n                app.handle_key(key);\n                log_state!(\"key\");\n            }\n            AppEvent::Tick => {\n                app.state.tick();\n\n                // Poll chat stream if active - keep polling until done regardless of mode\n                // (mode may change to Review before we receive the done event with session_id)\n                if let Some(stream) = &mut chat_stream {\n                    let mut cx = std::task::Context::from_waker(futures::task::noop_waker_ref());\n                    match stream.as_mut().poll_next(&mut cx) {\n                        std::task::Poll::Ready(Some(Ok(event))) => match event {\n                            ChatStreamEvent::TextChunk(text) => {\n                                trace!(text = %text, \"Processing TextChunk\");\n                                app.state.append_streaming_text(&text);\n                                log_state!(\"text_chunk\");\n                            }\n                            ChatStreamEvent::ToolCall { id, name, input } => {\n                                trace!(id = %id, name = %name, \"Processing ToolCall\");\n                                app.state.add_tool_call(id, name, input);\n                                log_state!(\"tool_call\");\n                            }\n                            ChatStreamEvent::ToolResult {\n                                tool_use_id,\n                                content,\n                                is_error,\n                            } => {\n                                trace!(tool_use_id = %tool_use_id, \"Processing ToolResult\");\n                                app.state.add_tool_result(tool_use_id, content, is_error);\n                                log_state!(\"tool_result\");\n                            }\n                            ChatStreamEvent::Status(status) => {\n                                trace!(status = %status, \"Processing Status\");\n                                app.state.update_streaming_status(&status);\n                                log_state!(\"status\");\n                            }\n                            ChatStreamEvent::Done { session_id } => {\n                                trace!(session_id = %session_id, \"Processing Done\");\n                                chat_stream = None;\n                                if !session_id.is_empty() {\n                                    app.state.store_session_id(session_id);\n                                }\n                                app.state.finalize_streaming();\n                                log_state!(\"done\");\n                            }\n                            ChatStreamEvent::Error(msg) => {\n                                trace!(error = %msg, \"Processing Error\");\n                                chat_stream = None;\n                                app.state.streaming_error(msg);\n                                log_state!(\"error\");\n                            }\n                        },\n                        std::task::Poll::Ready(Some(Err(e))) => {\n                            chat_stream = None;\n                            app.state.streaming_error(e.to_string());\n                            log_state!(\"stream_error\");\n                        }\n                        std::task::Poll::Ready(None) => {\n                            chat_stream = None;\n                            app.state.finalize_streaming();\n                            log_state!(\"stream_end\");\n                        }\n                        std::task::Poll::Pending => {}\n                    }\n                }\n            }\n            _ => {}\n        }\n\n        // Handle user cancellation (Esc during streaming) - drop the stream\n        if app.state.was_interrupted && chat_stream.is_some() {\n            debug!(\"User cancelled streaming, dropping chat stream\");\n            chat_stream = None;\n            app.state.was_interrupted = false; // Reset the flag\n        }\n\n        // Check exit condition (includes Ctrl+C / SIGINT from event loop)\n        if app.state.should_exit || event_loop.is_shutdown() {\n            break;\n        }\n\n        // Handle generation trigger - unified path for all turns\n        if app.state.mode == AppMode::Generating && chat_stream.is_none() {\n            // Get the last user message from events\n            let last_user_content = app.state.events.iter().rev().find_map(|e| {\n                if let ConversationEvent::UserMessage { content } = e {\n                    Some(content.clone())\n                } else {\n                    None\n                }\n            });\n\n            if last_user_content.is_some() {\n                // Build messages in Claude API format\n                let messages = app.state.events_to_messages();\n\n                // Transition to streaming mode\n                app.state.start_streaming();\n                log_state!(\"start_streaming\");\n\n                // Start the chat stream\n                chat_stream = Some(create_chat_stream(\n                    endpoint.clone(),\n                    token.clone(),\n                    app.state.session_id.clone(),\n                    messages,\n                    settings,\n                ));\n            }\n        }\n    }\n\n    // Restore popup area before guard drops (guard skips cleanup in popup mode)\n    #[cfg(unix)]\n    if let Some(ref ps) = popup_state {\n        crate::tui::popup::restore(ps);\n    }\n\n    // Map exit action to return value\n    let result = match app.state.exit_action {\n        Some(ExitAction::Execute(cmd)) => Action::Execute(cmd),\n        Some(ExitAction::Insert(cmd)) => Action::Insert(cmd),\n        _ => Action::Cancel,\n    };\n\n    Ok(result)\n}\n\nstruct RawModeGuard;\n\nimpl Drop for RawModeGuard {\n    fn drop(&mut self) {\n        let _ = disable_raw_mode();\n    }\n}\n\nfn emit_shell_result(action: Action, output_for_hook: bool) {\n    if output_for_hook {\n        match action {\n            Action::Execute(output) => eprintln!(\"__atuin_ai_execute__:{output}\"),\n            Action::Insert(output) => eprintln!(\"__atuin_ai_insert__:{output}\"),\n            Action::Print(output) => eprintln!(\"__atuin_ai_print__:{output}\"),\n            Action::Cancel => eprintln!(\"__atuin_ai_cancel__\"),\n        }\n    } else {\n        match action {\n            Action::Execute(output) => eprintln!(\"{output}\"),\n            Action::Insert(output) => eprintln!(\"{output}\"),\n            Action::Print(output) => eprintln!(\"{output}\"),\n            Action::Cancel => eprintln!(),\n        }\n    }\n}\n\nfn wait_for_login_confirmation() -> Result<bool> {\n    enable_raw_mode().context(\"failed enabling raw mode for login prompt\")?;\n    let _guard = RawModeGuard;\n\n    loop {\n        let ev = event::read().context(\"failed to read login confirmation key\")?;\n        if let Event::Key(key) = ev {\n            match key.code {\n                KeyCode::Enter => return Ok(true),\n                KeyCode::Esc => return Ok(false),\n                _ => {}\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "crates/atuin-ai/src/commands.rs",
    "content": "use std::{\n    fs,\n    path::{Path, PathBuf},\n};\n\nuse atuin_common::shell::Shell;\nuse clap::{Args, Subcommand};\nuse eyre::Result;\nuse tracing_appender::rolling::{RollingFileAppender, Rotation};\nuse tracing_subscriber::{EnvFilter, Layer, fmt, layer::SubscriberExt, util::SubscriberInitExt};\n#[cfg(debug_assertions)]\npub mod debug_render;\n\npub mod init;\npub mod inline;\n\n#[derive(Args, Debug)]\npub struct AiArgs {\n    /// Enable verbose logging\n    #[arg(short, long, global = true)]\n    verbose: bool,\n\n    /// Custom API endpoint; defaults to reading from the `ai.endpoint` setting.\n    #[arg(long, global = true)]\n    api_endpoint: Option<String>,\n\n    /// Custom API token; defaults to reading from the `ai.api_token` setting.\n    #[arg(long, global = true)]\n    api_token: Option<String>,\n}\n\n#[derive(Subcommand, Debug)]\npub enum Commands {\n    /// Initialize shell integration\n    Init {\n        /// Shell to generate integration for; defaults to \"auto\"\n        #[arg(value_name = \"SHELL\", default_value = \"auto\")]\n        shell: String,\n    },\n\n    /// Inline completion mode with small TUI overlay\n    Inline {\n        #[command(flatten)]\n        args: AiArgs,\n\n        /// Current command line to complete\n        #[arg(value_name = \"COMMAND\")]\n        command: Option<String>,\n\n        /// Keep TUI output visible after exit (default: erase)\n        #[arg(long)]\n        keep: bool,\n\n        /// Use the hook mode\n        #[arg(long, hide = true)]\n        hook: bool,\n\n        /// Log state changes to file for debugging (dev tool)\n        #[arg(long, value_name = \"FILE\", hide = true)]\n        debug_state: Option<String>,\n    },\n\n    /// Debug render: output a single frame from JSON state (dev tool)\n    #[cfg(debug_assertions)]\n    DebugRender {\n        /// Input file (reads from stdin if not provided)\n        #[arg(short, long)]\n        input: Option<String>,\n\n        /// Output format: ansi (default), plain, json\n        #[arg(short, long, default_value = \"ansi\")]\n        format: String,\n    },\n}\n\npub async fn run(\n    command: Commands,\n    settings: &atuin_client::settings::Settings,\n) -> eyre::Result<()> {\n    match command {\n        Commands::Init { shell } => init::run(shell).await,\n        Commands::Inline {\n            command,\n            keep,\n            debug_state,\n            hook,\n            args,\n            ..\n        } => {\n            if settings.logs.ai_enabled() {\n                init_logging(settings, args.verbose)?;\n            }\n\n            inline::run(\n                command,\n                args.api_endpoint,\n                args.api_token,\n                keep,\n                debug_state,\n                settings,\n                hook,\n            )\n            .await\n        }\n        #[cfg(debug_assertions)]\n        Commands::DebugRender { input, format } => {\n            let output_format = match format.as_str() {\n                \"plain\" => debug_render::OutputFormat::Plain,\n                \"json\" => debug_render::OutputFormat::Json,\n                _ => debug_render::OutputFormat::Ansi,\n            };\n            debug_render::run(input, output_format).await\n        }\n    }\n}\n\npub fn detect_shell() -> Option<String> {\n    Some(Shell::current().to_string())\n}\n\n/// Initializes logging for the AI commands.\nfn init_logging(settings: &atuin_client::settings::Settings, verbose: bool) -> Result<()> {\n    // ATUIN_LOG env var overrides config file level settings\n    let env_log_set = std::env::var(\"ATUIN_LOG\").is_ok();\n\n    // Base filter from env var (or empty if not set)\n    let base_filter =\n        EnvFilter::from_env(\"ATUIN_LOG\").add_directive(\"sqlx_sqlite::regexp=off\".parse()?);\n\n    // Use config level unless ATUIN_LOG is set\n    let filter = if env_log_set {\n        base_filter\n    } else {\n        EnvFilter::default()\n            .add_directive(settings.logs.ai_level().as_directive().parse()?)\n            .add_directive(\"sqlx_sqlite::regexp=off\".parse()?)\n    };\n\n    let log_dir = PathBuf::from(&settings.logs.dir);\n    let ai_log_filename = settings.logs.ai.file.clone();\n\n    // Clean up old log files\n    cleanup_old_logs(&log_dir, &ai_log_filename, settings.logs.ai_retention());\n\n    let console_layer = if verbose {\n        Some(\n            fmt::layer()\n                .with_writer(std::io::stderr)\n                .with_ansi(true)\n                .with_target(false)\n                .with_filter(filter.clone()),\n        )\n    } else {\n        None\n    };\n\n    let file_appender = RollingFileAppender::new(Rotation::DAILY, &log_dir, &ai_log_filename);\n\n    let base = tracing_subscriber::registry().with(\n        fmt::layer()\n            .with_writer(file_appender)\n            .with_ansi(false)\n            .with_filter(filter),\n    );\n\n    if let Some(console_layer) = console_layer {\n        base.with(console_layer).init();\n    } else {\n        base.init();\n    };\n\n    Ok(())\n}\n\nfn cleanup_old_logs(log_dir: &Path, prefix: &str, retention_days: u64) {\n    let cutoff = std::time::SystemTime::now()\n        - std::time::Duration::from_secs(retention_days * 24 * 60 * 60);\n\n    let Ok(entries) = fs::read_dir(log_dir) else {\n        return;\n    };\n\n    for entry in entries.flatten() {\n        let path = entry.path();\n        let Some(name) = path.file_name().and_then(|n| n.to_str()) else {\n            continue;\n        };\n\n        // Match files like \"search.log.2024-02-23\" or \"daemon.log.2024-02-23\"\n        if !name.starts_with(prefix) || name == prefix {\n            continue;\n        }\n\n        if let Ok(metadata) = entry.metadata()\n            && let Ok(modified) = metadata.modified()\n            && modified < cutoff\n        {\n            let _ = fs::remove_file(&path);\n        }\n    }\n}\n"
  },
  {
    "path": "crates/atuin-ai/src/lib.rs",
    "content": "pub mod commands;\npub mod tui;\n"
  },
  {
    "path": "crates/atuin-ai/src/tui/app.rs",
    "content": "use super::state::{AppMode, AppState, ExitAction};\nuse crossterm::event::{KeyCode, KeyEvent, KeyModifiers};\nuse tui_textarea::{Input, Key};\n\n/// Thin wrapper around AppState for compatibility\n/// All state lives in AppState, this just provides the handle_key interface\npub struct App {\n    pub state: AppState,\n}\n\nimpl App {\n    pub fn new() -> Self {\n        Self {\n            state: AppState::new(),\n        }\n    }\n\n    /// Handle a key event. Returns true if render is needed.\n    pub fn handle_key(&mut self, key: KeyEvent) -> bool {\n        match self.state.mode {\n            AppMode::Input => self.handle_input_key(key),\n            AppMode::Generating => self.handle_generating_key(key),\n            AppMode::Streaming => self.handle_streaming_key(key),\n            AppMode::Review => self.handle_review_key(key),\n            AppMode::Error => self.handle_error_key(key),\n        }\n    }\n\n    fn handle_input_key(&mut self, key: KeyEvent) -> bool {\n        // Handle special keys ourselves\n        match key.code {\n            KeyCode::Esc => {\n                self.state.exit(ExitAction::Cancel);\n                return true;\n            }\n            KeyCode::Enter => {\n                if self.state.input_is_empty() {\n                    self.state.exit(ExitAction::Cancel);\n                } else {\n                    self.state.start_generating();\n                }\n                return true;\n            }\n            _ => {}\n        }\n\n        // Delegate all other keys to textarea\n        // Manually convert crossterm KeyEvent to tui-textarea Input\n        // (needed due to crossterm version mismatch)\n        let tui_key = match key.code {\n            KeyCode::Char(c) => Key::Char(c),\n            KeyCode::Backspace => Key::Backspace,\n            KeyCode::Delete => Key::Delete,\n            KeyCode::Left => Key::Left,\n            KeyCode::Right => Key::Right,\n            KeyCode::Up => Key::Up,\n            KeyCode::Down => Key::Down,\n            KeyCode::Home => Key::Home,\n            KeyCode::End => Key::End,\n            KeyCode::PageUp => Key::PageUp,\n            KeyCode::PageDown => Key::PageDown,\n            KeyCode::Tab => Key::Tab,\n            _ => Key::Null,\n        };\n\n        if tui_key != Key::Null {\n            let input = Input {\n                key: tui_key,\n                ctrl: key.modifiers.contains(KeyModifiers::CONTROL),\n                alt: key.modifiers.contains(KeyModifiers::ALT),\n                shift: key.modifiers.contains(KeyModifiers::SHIFT),\n            };\n            self.state.textarea.input(input);\n        }\n        true\n    }\n\n    fn handle_generating_key(&mut self, key: KeyEvent) -> bool {\n        match key.code {\n            KeyCode::Esc => {\n                self.state.cancel_generation();\n                true\n            }\n            _ => false, // Discard other keys during generation\n        }\n    }\n\n    fn handle_streaming_key(&mut self, key: KeyEvent) -> bool {\n        match key.code {\n            KeyCode::Esc => {\n                self.state.cancel_streaming();\n                true\n            }\n            _ => false, // Ignore other keys during streaming\n        }\n    }\n\n    fn handle_review_key(&mut self, key: KeyEvent) -> bool {\n        match key.code {\n            KeyCode::Esc => {\n                self.state.confirmation_pending = false; // Clear confirmation state\n                self.state.exit(ExitAction::Cancel);\n                true\n            }\n            KeyCode::Enter => {\n                let cmd = self.state.current_command().map(|c| c.to_string());\n                if let Some(cmd) = cmd {\n                    if self.state.is_current_command_dangerous() && !self.state.confirmation_pending\n                    {\n                        // First Enter on dangerous command: enter confirmation mode\n                        self.state.confirmation_pending = true;\n                    } else {\n                        // Second Enter (confirmation), or non-dangerous command: execute\n                        self.state.confirmation_pending = false;\n                        self.state.exit(ExitAction::Execute(cmd));\n                    }\n                }\n                true\n            }\n            KeyCode::Tab => {\n                let cmd = self.state.current_command().map(|c| c.to_string());\n                if let Some(cmd) = cmd {\n                    self.state.confirmation_pending = false; // Clear on Tab too\n                    self.state.exit(ExitAction::Insert(cmd));\n                }\n                true\n            }\n            KeyCode::Char('f') => {\n                // Changed from 'e' to 'f' for follow-up mode\n                self.state.confirmation_pending = false; // Clear on follow-up\n                self.state.start_edit_mode();\n                true\n            }\n            _ => false,\n        }\n    }\n\n    fn handle_error_key(&mut self, key: KeyEvent) -> bool {\n        match key.code {\n            KeyCode::Esc => {\n                self.state.exit(ExitAction::Cancel);\n                true\n            }\n            KeyCode::Enter | KeyCode::Char('r') => {\n                self.state.retry();\n                true\n            }\n            _ => false,\n        }\n    }\n}\n\nimpl Default for App {\n    fn default() -> Self {\n        Self::new()\n    }\n}\n"
  },
  {
    "path": "crates/atuin-ai/src/tui/component.rs",
    "content": "//! Component-oriented rendering primitives for the TUI.\n//!\n//! Defines the `Component` trait and container types (`VStack`, `SymbolRow`, etc.)\n//! that enable declarative, composable UI layout.\n\nuse atuin_client::theme::{Meaning, Theme};\nuse ratatui::{\n    Frame, backend::FromCrossterm, layout::Rect, style::Style, text::Span, widgets::Paragraph,\n};\nuse tui_textarea::TextArea;\n\n/// Context passed through the component tree during rendering.\npub struct RenderContext<'a> {\n    pub theme: &'a Theme,\n    pub anchor_col: u16,\n    pub textarea: Option<&'a TextArea<'static>>,\n    /// Maximum viewport height (for scroll calculations)\n    pub max_height: u16,\n    /// When true, the viewport is a fixed rect already positioned for the card.\n    /// The card fills the entire viewport instead of positioning via anchor_col.\n    pub popup_mode: bool,\n    /// When true, blocks are rendered in reverse order so that the input field\n    /// appears at the bottom of the card (close to the prompt when the popup\n    /// is above the cursor).\n    pub render_above: bool,\n}\n\n/// A renderable component with intrinsic sizing.\npub trait Component {\n    /// Calculate the intrinsic height at the given width.\n    fn height(&self, width: u16) -> u16;\n\n    /// Render into the given area.\n    fn render(&self, frame: &mut Frame, area: Rect, ctx: &RenderContext);\n}\n\n/// Vertical stack of components.\n///\n/// Children are laid out top-to-bottom with optional spacing between them.\n/// When `scroll_offset > 0`, content is scrolled so that only the visible\n/// portion is rendered.\npub struct VStack {\n    pub children: Vec<Box<dyn Component>>,\n    pub spacing: u16,\n    pub scroll_offset: u16,\n}\n\nimpl VStack {\n    pub fn new(children: Vec<Box<dyn Component>>) -> Self {\n        Self {\n            children,\n            spacing: 0,\n            scroll_offset: 0,\n        }\n    }\n}\n\nimpl Component for VStack {\n    fn height(&self, width: u16) -> u16 {\n        if self.children.is_empty() {\n            return 0;\n        }\n        let content: u16 = self.children.iter().map(|c| c.height(width)).sum();\n        let gaps = (self.children.len() as u16 - 1) * self.spacing;\n        content + gaps\n    }\n\n    fn render(&self, frame: &mut Frame, area: Rect, ctx: &RenderContext) {\n        if self.children.is_empty() {\n            return;\n        }\n\n        let heights: Vec<u16> = self.children.iter().map(|c| c.height(area.width)).collect();\n\n        let viewport_start = self.scroll_offset;\n        let viewport_end = self.scroll_offset + area.height;\n\n        let mut cum: u16 = 0;\n        for (i, (child, &h)) in self.children.iter().zip(heights.iter()).enumerate() {\n            let child_start = cum;\n            let child_end = cum + h;\n\n            // Render if any part of the child is within the viewport\n            if child_end > viewport_start && child_start < viewport_end {\n                let visible_start = child_start.max(viewport_start);\n                let visible_end = child_end.min(viewport_end);\n\n                let child_area = Rect {\n                    x: area.x,\n                    y: area.y + visible_start - viewport_start,\n                    width: area.width,\n                    height: visible_end - visible_start,\n                };\n\n                child.render(frame, child_area, ctx);\n            }\n\n            cum = child_end;\n            if i < self.children.len() - 1 {\n                cum += self.spacing;\n            }\n        }\n    }\n}\n\n/// Fixed-height empty space.\npub struct Spacer(pub u16);\n\nimpl Component for Spacer {\n    fn height(&self, _width: u16) -> u16 {\n        self.0\n    }\n\n    fn render(&self, _frame: &mut Frame, _area: Rect, _ctx: &RenderContext) {}\n}\n\n/// A row with a symbol in column 0 and content in columns 2+.\n///\n/// This is the horizontal layout primitive used by all content types that\n/// display a prefix symbol (>, $, !, ?, etc.) followed by text.\npub struct SymbolRow {\n    pub symbol: String,\n    pub symbol_meaning: Meaning,\n    pub inner: Box<dyn Component>,\n}\n\nimpl Component for SymbolRow {\n    fn height(&self, width: u16) -> u16 {\n        self.inner.height(width.saturating_sub(2))\n    }\n\n    fn render(&self, frame: &mut Frame, area: Rect, ctx: &RenderContext) {\n        // Render symbol at column 0, first row only\n        let style = Style::from_crossterm(ctx.theme.as_style(self.symbol_meaning));\n        let symbol_area = Rect {\n            x: area.x,\n            y: area.y,\n            width: 1,\n            height: 1,\n        };\n        frame.render_widget(\n            Paragraph::new(self.symbol.as_str()).style(style),\n            symbol_area,\n        );\n\n        // Render inner content at column 2+\n        let content_area = Rect {\n            x: area.x.saturating_add(2),\n            y: area.y,\n            width: area.width.saturating_sub(2),\n            height: area.height,\n        };\n        self.inner.render(frame, content_area, ctx);\n    }\n}\n\n/// Horizontal separator spanning the full card width (├───┤).\n///\n/// Extends beyond its content area to overlap the card's left and right borders.\npub struct Separator {\n    pub card_width: u16,\n}\n\nimpl Component for Separator {\n    fn height(&self, _width: u16) -> u16 {\n        1\n    }\n\n    fn render(&self, frame: &mut Frame, area: Rect, ctx: &RenderContext) {\n        let style = Style::from_crossterm(ctx.theme.as_style(Meaning::Base));\n        let inner_width = self.card_width.saturating_sub(2) as usize;\n        let separator = format!(\n            \"\\u{251c}{}\\u{2524}\",           // ├ ... ┤\n            \"\\u{2500}\".repeat(inner_width)  // ─\n        );\n\n        // Extend left to overlap the card border (content area is inset by border + padding)\n        let sep_area = Rect {\n            x: area.x.saturating_sub(2),\n            y: area.y,\n            width: self.card_width,\n            height: 1,\n        };\n        frame.render_widget(Paragraph::new(Span::styled(separator, style)), sep_area);\n    }\n}\n"
  },
  {
    "path": "crates/atuin-ai/src/tui/components.rs",
    "content": "//! Leaf components for each content type and factory functions for building\n//! the component tree from the view model.\n\nuse atuin_client::theme::{Meaning, Theme};\nuse ratatui::{\n    Frame,\n    backend::FromCrossterm,\n    layout::Rect,\n    style::{Modifier, Style},\n    text::{Line, Span},\n    widgets::{Paragraph, Wrap},\n};\n\nuse super::component::{Component, RenderContext, Separator, Spacer, SymbolRow, VStack};\nuse super::spinner::active_frame;\nuse super::view_model::{Block, Content, WarningKind};\n\n// ---------------------------------------------------------------------------\n// Text measurement utilities\n// ---------------------------------------------------------------------------\n\n/// Count lines when text is wrapped at given width.\n/// Uses ratatui's Paragraph::line_count for accurate wrapping calculation.\npub(crate) fn line_count_wrapped(text: &str, width: usize) -> u16 {\n    if width == 0 {\n        return 1;\n    }\n    let paragraph = Paragraph::new(text).wrap(Wrap { trim: false });\n    paragraph.line_count(width as u16).max(1) as u16\n}\n\n/// Count lines using word-wrap algorithm (matches TextArea's WrapMode::Word).\n/// Words won't be broken mid-word, so this may produce more lines than character wrapping.\n/// Returns (line_count, last_line_width) so caller can determine if cursor needs extra space.\npub(crate) fn word_wrap_line_count_with_last_width(text: &str, width: usize) -> (u16, usize) {\n    if width == 0 || text.is_empty() {\n        return (1, 0);\n    }\n\n    let mut line_count = 0u16;\n    let mut current_line_width = 0usize;\n\n    for line in text.lines() {\n        if line.is_empty() {\n            line_count += 1;\n            current_line_width = 0;\n            continue;\n        }\n\n        let mut line_started = false;\n\n        for word in line.split_whitespace() {\n            let word_width = unicode_width::UnicodeWidthStr::width(word);\n\n            if !line_started {\n                if word_width > width {\n                    line_count += word_width.div_ceil(width) as u16;\n                    current_line_width = word_width % width;\n                    if current_line_width == 0 {\n                        current_line_width = 0;\n                        line_started = false;\n                    } else {\n                        line_started = true;\n                    }\n                } else {\n                    current_line_width = word_width;\n                    line_started = true;\n                }\n            } else {\n                let needed = current_line_width + 1 + word_width;\n                if needed > width {\n                    line_count += 1;\n                    if word_width > width {\n                        line_count += word_width.div_ceil(width) as u16;\n                        current_line_width = word_width % width;\n                        if current_line_width == 0 {\n                            line_started = false;\n                        }\n                    } else {\n                        current_line_width = word_width;\n                    }\n                } else {\n                    current_line_width = needed;\n                }\n            }\n        }\n\n        if line_started {\n            line_count += 1;\n        }\n    }\n\n    if line_count == 0 {\n        line_count = 1;\n        current_line_width = 0;\n    }\n\n    (line_count, current_line_width)\n}\n\n// ---------------------------------------------------------------------------\n// Inline markdown formatting\n// ---------------------------------------------------------------------------\n\n/// Parse inline markdown formatting (**bold** and `code`) into styled spans.\n/// Preserves all other text — list prefixes, indentation, and line structure\n/// are left exactly as-is.\nfn style_inline_markdown(text: &str, theme: &Theme) -> Vec<Line<'static>> {\n    let base_style = Style::from_crossterm(theme.as_style(Meaning::Base));\n    let code_style = Style::from_crossterm(theme.as_style(Meaning::Guidance));\n    let bold_style = base_style.add_modifier(Modifier::BOLD);\n\n    text.lines()\n        .map(|line| {\n            Line::from(parse_inline_formatting(\n                line, base_style, bold_style, code_style,\n            ))\n        })\n        .collect()\n}\n\n/// Parse a single line for `code` and **bold** markers, returning styled spans.\nfn parse_inline_formatting(\n    line: &str,\n    base: Style,\n    bold: Style,\n    code: Style,\n) -> Vec<Span<'static>> {\n    let mut spans = Vec::new();\n    let mut current = String::new();\n    let mut chars = line.chars().peekable();\n\n    while let Some(ch) = chars.next() {\n        if ch == '`' {\n            // Flush accumulated plain text\n            if !current.is_empty() {\n                spans.push(Span::styled(std::mem::take(&mut current), base));\n            }\n            // Collect until closing backtick\n            let mut code_text = String::new();\n            let mut closed = false;\n            for next in chars.by_ref() {\n                if next == '`' {\n                    closed = true;\n                    break;\n                }\n                code_text.push(next);\n            }\n            if closed {\n                spans.push(Span::styled(code_text, code));\n            } else {\n                // Unclosed backtick — render as-is\n                current.push('`');\n                current.push_str(&code_text);\n            }\n        } else if ch == '*' && chars.peek() == Some(&'*') {\n            chars.next(); // consume second *\n            // Flush accumulated plain text\n            if !current.is_empty() {\n                spans.push(Span::styled(std::mem::take(&mut current), base));\n            }\n            // Collect until closing **\n            let mut bold_text = String::new();\n            let mut closed = false;\n            while let Some(next) = chars.next() {\n                if next == '*' && chars.peek() == Some(&'*') {\n                    chars.next();\n                    closed = true;\n                    break;\n                }\n                bold_text.push(next);\n            }\n            if closed {\n                spans.push(Span::styled(bold_text, bold));\n            } else {\n                // Unclosed ** — render as-is\n                current.push_str(\"**\");\n                current.push_str(&bold_text);\n            }\n        } else {\n            current.push(ch);\n        }\n    }\n\n    if !current.is_empty() {\n        spans.push(Span::styled(current, base));\n    }\n\n    spans\n}\n\n// ---------------------------------------------------------------------------\n// Leaf components\n// ---------------------------------------------------------------------------\n\n/// User input display (active textarea or static text).\npub struct InputContent {\n    pub text: String,\n    pub active: bool,\n}\n\nimpl Component for InputContent {\n    fn height(&self, width: u16) -> u16 {\n        let w = width as usize;\n        if self.active {\n            let (lines, last_width) = word_wrap_line_count_with_last_width(&self.text, w);\n            if last_width >= w {\n                lines.saturating_add(1)\n            } else {\n                lines\n            }\n        } else {\n            line_count_wrapped(&self.text, w)\n        }\n    }\n\n    fn render(&self, frame: &mut Frame, area: Rect, ctx: &RenderContext) {\n        if self.active {\n            if let Some(textarea) = ctx.textarea {\n                frame.render_widget(textarea, area);\n            }\n        } else {\n            let style = Style::from_crossterm(ctx.theme.as_style(Meaning::Base));\n            frame.render_widget(\n                Paragraph::new(self.text.as_str())\n                    .style(style)\n                    .wrap(Wrap { trim: false }),\n                area,\n            );\n        }\n    }\n}\n\n/// Command suggestion ($ prefix).\npub struct CommandContent {\n    pub text: String,\n    pub faded: bool,\n}\n\nimpl Component for CommandContent {\n    fn height(&self, width: u16) -> u16 {\n        line_count_wrapped(&self.text, width as usize)\n    }\n\n    fn render(&self, frame: &mut Frame, area: Rect, ctx: &RenderContext) {\n        let mut style = Style::from_crossterm(ctx.theme.as_style(Meaning::Base));\n        if self.faded {\n            style = style.add_modifier(Modifier::DIM);\n        }\n        frame.render_widget(\n            Paragraph::new(self.text.as_str())\n                .style(style)\n                .wrap(Wrap { trim: false }),\n            area,\n        );\n    }\n}\n\n/// Markdown text content (indented, no symbol).\npub struct TextContent {\n    pub markdown: String,\n}\n\nimpl Component for TextContent {\n    fn height(&self, width: u16) -> u16 {\n        // Height uses raw text — slightly overestimates since markdown syntax\n        // characters (**, `) are stripped in rendering, but this is harmless\n        // (allocates equal or more space than needed, never less).\n        line_count_wrapped(&self.markdown, width as usize)\n    }\n\n    fn render(&self, frame: &mut Frame, area: Rect, ctx: &RenderContext) {\n        let lines = style_inline_markdown(&self.markdown, ctx.theme);\n        let paragraph = Paragraph::new(lines).wrap(Wrap { trim: false });\n        frame.render_widget(paragraph, area);\n    }\n}\n\n/// Error message (! prefix).\npub struct ErrorContent {\n    pub message: String,\n}\n\nimpl Component for ErrorContent {\n    fn height(&self, width: u16) -> u16 {\n        line_count_wrapped(&self.message, width as usize)\n    }\n\n    fn render(&self, frame: &mut Frame, area: Rect, ctx: &RenderContext) {\n        let style = Style::from_crossterm(ctx.theme.as_style(Meaning::Base));\n        frame.render_widget(\n            Paragraph::new(self.message.as_str())\n                .style(style)\n                .wrap(Wrap { trim: false }),\n            area,\n        );\n    }\n}\n\n/// Warning for dangerous or low-confidence commands.\npub struct WarningContent {\n    pub kind: WarningKind,\n    pub text: String,\n    pub pending_confirm: bool,\n}\n\nimpl Component for WarningContent {\n    fn height(&self, width: u16) -> u16 {\n        let display_text = if self.pending_confirm {\n            \"Press Enter again to run this dangerous command\"\n        } else {\n            self.text.as_str()\n        };\n        line_count_wrapped(display_text, width as usize)\n    }\n\n    fn render(&self, frame: &mut Frame, area: Rect, ctx: &RenderContext) {\n        let style = Style::from_crossterm(ctx.theme.as_style(Meaning::Base));\n        let display_text = if self.pending_confirm {\n            \"Press Enter again to run this dangerous command\"\n        } else {\n            self.text.as_str()\n        };\n        frame.render_widget(\n            Paragraph::new(display_text)\n                .style(style)\n                .wrap(Wrap { trim: false }),\n            area,\n        );\n    }\n}\n\n/// Animated spinner with status text.\npub struct SpinnerContent {\n    pub status_text: String,\n}\n\nimpl Component for SpinnerContent {\n    fn height(&self, _width: u16) -> u16 {\n        1\n    }\n\n    fn render(&self, frame: &mut Frame, area: Rect, ctx: &RenderContext) {\n        let style = Style::from_crossterm(ctx.theme.as_style(Meaning::Annotation));\n        frame.render_widget(Paragraph::new(self.status_text.as_str()).style(style), area);\n    }\n}\n\n/// Tool call progress (in-flight spinner or completed checkmark).\npub struct ToolStatusContent {\n    pub completed_count: usize,\n    pub current_label: Option<String>,\n    pub frame: usize,\n}\n\nimpl Component for ToolStatusContent {\n    fn height(&self, _width: u16) -> u16 {\n        1\n    }\n\n    fn render(&self, frame: &mut Frame, area: Rect, ctx: &RenderContext) {\n        let style = Style::from_crossterm(ctx.theme.as_style(Meaning::Annotation));\n        let text = if let Some(ref label) = self.current_label {\n            if self.completed_count > 0 {\n                format!(\n                    \"{} (used {} tool{})\",\n                    label,\n                    self.completed_count,\n                    if self.completed_count == 1 { \"\" } else { \"s\" }\n                )\n            } else {\n                label.clone()\n            }\n        } else {\n            format!(\n                \"Used {} tool{}\",\n                self.completed_count,\n                if self.completed_count == 1 { \"\" } else { \"s\" }\n            )\n        };\n        frame.render_widget(Paragraph::new(text).style(style), area);\n    }\n}\n\n// ---------------------------------------------------------------------------\n// Factory functions\n// ---------------------------------------------------------------------------\n\n/// Convert a view model `Content` item into a `SymbolRow`-wrapped component.\nfn content_to_component(content: &Content) -> Box<dyn Component> {\n    match content {\n        Content::Input { text, active, .. } => Box::new(SymbolRow {\n            symbol: \">\".to_string(),\n            symbol_meaning: Meaning::Guidance,\n            inner: Box::new(InputContent {\n                text: text.clone(),\n                active: *active,\n            }),\n        }),\n\n        Content::Command { text, faded } => Box::new(SymbolRow {\n            symbol: \"$\".to_string(),\n            symbol_meaning: Meaning::Important,\n            inner: Box::new(CommandContent {\n                text: text.clone(),\n                faded: *faded,\n            }),\n        }),\n\n        Content::Text { markdown } => Box::new(SymbolRow {\n            symbol: \" \".to_string(),\n            symbol_meaning: Meaning::Base,\n            inner: Box::new(TextContent {\n                markdown: markdown.clone(),\n            }),\n        }),\n\n        Content::Error { message } => Box::new(SymbolRow {\n            symbol: \"!\".to_string(),\n            symbol_meaning: Meaning::AlertError,\n            inner: Box::new(ErrorContent {\n                message: message.clone(),\n            }),\n        }),\n\n        Content::Warning {\n            kind,\n            text,\n            pending_confirm,\n        } => {\n            let (symbol, meaning) = match kind {\n                WarningKind::Danger => (\"!\", Meaning::AlertError),\n                WarningKind::LowConfidence => (\"?\", Meaning::AlertWarn),\n            };\n            Box::new(SymbolRow {\n                symbol: symbol.to_string(),\n                symbol_meaning: meaning,\n                inner: Box::new(WarningContent {\n                    kind: *kind,\n                    text: text.clone(),\n                    pending_confirm: *pending_confirm,\n                }),\n            })\n        }\n\n        Content::Spinner { frame, status_text } => Box::new(SymbolRow {\n            symbol: active_frame(*frame).to_string(),\n            symbol_meaning: Meaning::Annotation,\n            inner: Box::new(SpinnerContent {\n                status_text: status_text.clone(),\n            }),\n        }),\n\n        Content::ToolStatus {\n            completed_count,\n            current_label,\n            frame,\n        } => {\n            let symbol = if current_label.is_some() {\n                active_frame(*frame).to_string()\n            } else {\n                \"\\u{2713}\".to_string() // ✓\n            };\n            Box::new(SymbolRow {\n                symbol,\n                symbol_meaning: Meaning::Annotation,\n                inner: Box::new(ToolStatusContent {\n                    completed_count: *completed_count,\n                    current_label: current_label.clone(),\n                    frame: *frame,\n                }),\n            })\n        }\n    }\n}\n\n/// Convert a view model `Block` into a `VStack` of content components.\nfn build_block_component(block: &Block) -> Box<dyn Component> {\n    let mut children: Vec<Box<dyn Component>> = Vec::new();\n\n    for (idx, content) in block.content.iter().enumerate() {\n        if idx > 0 {\n            children.push(Box::new(Spacer(1))); // blank line between items\n        }\n        children.push(content_to_component(content));\n    }\n\n    // Trailing blank line (padding after content)\n    children.push(Box::new(Spacer(1)));\n\n    Box::new(VStack::new(children))\n}\n\n/// Build the full component tree from an ordered list of view model blocks.\n///\n/// The tree is a `VStack` with blocks separated by `Separator` + `Spacer` pairs.\n/// The caller sets `scroll_offset` on the returned `VStack` before rendering.\npub fn build_component_tree(items: &[&Block], card_width: u16) -> VStack {\n    let mut children: Vec<Box<dyn Component>> = Vec::new();\n\n    for (idx, block) in items.iter().enumerate() {\n        if idx > 0 {\n            children.push(Box::new(Separator { card_width }));\n            children.push(Box::new(Spacer(1))); // leading blank after separator\n        }\n        children.push(build_block_component(block));\n    }\n\n    VStack::new(children)\n}\n"
  },
  {
    "path": "crates/atuin-ai/src/tui/event.rs",
    "content": "use crate::tui::App;\nuse crossterm::event::{Event, EventStream, KeyEvent, KeyEventKind};\nuse eyre::{Result, eyre};\nuse futures::StreamExt;\nuse std::time::Duration;\nuse tokio::time;\n\n/// Base tick interval for the event loop (fast for responsive streaming)\nconst BASE_TICK_INTERVAL: Duration = Duration::from_millis(50);\n\n/// Application events that drive the TUI state machine.\n///\n/// # Event Types\n/// - `Key`: Keyboard input (filtered to KeyEventKind::Press only)\n/// - `Tick`: Periodic event for updates (50ms base interval)\n/// - `Resize`: Terminal window resize\n/// - `StreamChunk/StreamDone/StreamError`: Placeholders for Phase 3 streaming\n///\n/// # Design Decisions\n/// - Fast 50ms base tick for responsive streaming; spinner timing handled in AppState\n/// - Stream events are placeholders - will be wired to channels in Phase 3\n/// - Resize handling enables responsive layout adjustments\n#[derive(Debug, Clone)]\npub enum AppEvent {\n    /// Keyboard input event (filtered to Press events only)\n    Key(KeyEvent),\n\n    /// Periodic tick for updates (50ms base interval; spinner timing in AppState)\n    Tick,\n\n    /// Terminal resize event (width, height)\n    Resize(u16, u16),\n\n    /// Stream chunk received (Phase 3 placeholder)\n    StreamChunk(String),\n\n    /// Stream completed successfully (Phase 3 placeholder)\n    StreamDone,\n\n    /// Stream error occurred (Phase 3 placeholder)\n    StreamError(String),\n}\n\n/// Async event loop that drives the TUI with prioritized event handling.\n///\n/// # Priority Model (Biased Select)\n/// 1. **Stream data** - Highest priority (future Phase 3 streaming)\n/// 2. **Keyboard input** - Medium priority (user responsiveness)\n/// 3. **Tick events** - Lowest priority (spinner animation)\n///\n/// This ensures stream data is processed immediately when available,\n/// keyboard input is responsive, and spinner updates don't block higher priority events.\n///\n/// # Graceful Shutdown\n/// - SIGINT (Ctrl+C) sets shutdown flag and breaks the loop\n/// - EventStream close (stdin EOF) triggers shutdown\n/// - Shutdown flag can be checked/set externally for controlled termination\n///\n/// # Example\n/// ```no_run\n/// use atuin_ai::tui::EventLoop;\n///\n/// # async fn example() -> eyre::Result<()> {\n/// let mut event_loop = EventLoop::new();\n/// loop {\n///     let event = event_loop.run().await?;\n///     // Handle event...\n///     # break;\n/// }\n/// # Ok(())\n/// # }\n/// ```\npub struct EventLoop {\n    /// Tick interval timer (created lazily on first run)\n    tick_timer: Option<time::Interval>,\n\n    /// Flag indicating a render was requested (future use in Phase 2)\n    #[allow(dead_code)]\n    render_requested: bool,\n\n    /// Shutdown flag - when true, event loop will terminate\n    shutdown: bool,\n}\n\nimpl EventLoop {\n    /// Create a new EventLoop with default settings.\n    ///\n    /// # Defaults\n    /// - Tick interval: 50ms base rate (spinner timing handled separately in AppState)\n    /// - Render requested: false\n    /// - Shutdown: false\n    pub fn new() -> Self {\n        Self {\n            tick_timer: None,\n            render_requested: false,\n            shutdown: false,\n        }\n    }\n\n    /// Run the event loop, returning the next application event.\n    ///\n    /// # Priority Model\n    /// Uses `tokio::select!` with `biased;` mode to enforce priority:\n    /// 1. Stream data (placeholder for Phase 3)\n    /// 2. Keyboard input with rapid keypress batching\n    /// 3. Tick for spinner animation\n    ///\n    /// # Keyboard Handling\n    /// - Filters to KeyEventKind::Press on all platforms for safety\n    /// - Batching of rapid keypresses will be implemented in Phase 2\n    /// - Currently returns individual key events\n    ///\n    /// # Graceful Shutdown\n    /// - SIGINT (Ctrl+C) triggers shutdown and returns last event\n    /// - EventStream close (stdin EOF) triggers shutdown\n    /// - Shutdown flag can be checked after this returns\n    ///\n    /// # Errors\n    /// - Returns error if terminal event stream encounters an error\n    /// - EventStream close is handled gracefully as shutdown signal\n    ///\n    /// # Example\n    /// ```no_run\n    /// # use atuin_ai::tui::EventLoop;\n    /// # async fn example() -> eyre::Result<()> {\n    /// let mut event_loop = EventLoop::new();\n    /// while !event_loop.is_shutdown() {\n    ///     match event_loop.run().await? {\n    ///         // Handle events...\n    ///         # _ => break,\n    ///     }\n    /// }\n    /// # Ok(())\n    /// # }\n    /// ```\n    pub async fn run(&mut self) -> Result<AppEvent> {\n        // Create async event stream for keyboard/terminal events\n        let mut reader = EventStream::new();\n\n        // Get or create the tick timer (reused across calls to maintain timing)\n        // Uses fast base tick for responsive streaming; spinner timing handled in AppState\n        let tick_timer = self.tick_timer.get_or_insert_with(|| {\n            let mut interval = time::interval(BASE_TICK_INTERVAL);\n            // Skip the first immediate tick\n            interval.reset();\n            interval\n        });\n\n        loop {\n            if self.shutdown {\n                break;\n            }\n\n            // Biased select: prioritize stream > keyboard > tick\n            let event = tokio::select! {\n                biased;\n\n                // Priority 1: Stream data (placeholder for Phase 3)\n                // In Phase 3, this will be:\n                // Some(chunk) = stream_rx.recv() => { ... }\n\n                // Priority 2: Keyboard input\n                maybe_event = reader.next() => {\n                    match maybe_event {\n                        Some(Ok(Event::Key(key))) => {\n                            // Filter to Press events only for cross-platform safety\n                            if key.kind == KeyEventKind::Press {\n                                // Note: Rapid keypress batching will be implemented in Phase 2\n                                // when we integrate with the state machine.\n                                // For now, just return individual key events.\n                                Some(AppEvent::Key(key))\n                            } else {\n                                None\n                            }\n                        }\n                        Some(Ok(Event::Resize(w, h))) => {\n                            Some(AppEvent::Resize(w, h))\n                        }\n                        Some(Err(e)) => {\n                            return Err(eyre!(\"terminal event error: {}\", e));\n                        }\n                        None => {\n                            // EventStream closed (stdin EOF) - trigger shutdown\n                            self.shutdown = true;\n                            None\n                        }\n                        _ => {\n                            // Ignore other event types (mouse, focus, etc.)\n                            None\n                        }\n                    }\n                }\n\n                // Priority 3: Tick for spinner animation\n                _ = tick_timer.tick() => {\n                    Some(AppEvent::Tick)\n                }\n\n                // SIGINT handling (Ctrl+C) - cross-platform\n                _ = tokio::signal::ctrl_c() => {\n                    self.shutdown = true;\n                    // Return one more event to allow graceful shutdown handling\n                    Some(AppEvent::Tick)\n                }\n            };\n\n            if let Some(app_event) = event {\n                return Ok(app_event);\n            }\n        }\n\n        // Loop exited due to shutdown - return final tick to allow cleanup\n        Ok(AppEvent::Tick)\n    }\n\n    /// Check if the event loop has been signaled to shut down.\n    ///\n    /// This can be used to cleanly exit the main TUI loop after receiving\n    /// a shutdown signal (Ctrl+C, stdin close, etc.)\n    pub fn is_shutdown(&self) -> bool {\n        self.shutdown\n    }\n\n    /// Signal the event loop to shut down.\n    ///\n    /// The shutdown will take effect on the next iteration of `run()`.\n    pub fn shutdown(&mut self) {\n        self.shutdown = true;\n    }\n\n    /// Poll for next event and apply to app state.\n    ///\n    /// This is a convenience method that combines `run()` with `App` state updates.\n    /// Returns true if app should continue, false if should exit.\n    ///\n    /// # Example\n    /// ```no_run\n    /// # use atuin_ai::tui::{EventLoop, App};\n    /// # async fn example() -> eyre::Result<()> {\n    /// let mut event_loop = EventLoop::new();\n    /// let mut app = App::new();\n    ///\n    /// while event_loop.poll_and_apply(&mut app).await? {\n    ///     // Render app state...\n    /// }\n    /// # Ok(())\n    /// # }\n    /// ```\n    pub async fn poll_and_apply(&mut self, app: &mut App) -> Result<bool> {\n        let event = self.run().await?;\n\n        match event {\n            AppEvent::Key(key) => {\n                app.handle_key(key);\n            }\n            AppEvent::Tick => {\n                app.state.tick();\n            }\n            AppEvent::Resize(_, _) => {\n                // Render will be triggered anyway\n            }\n            AppEvent::StreamChunk(_) | AppEvent::StreamDone | AppEvent::StreamError(_) => {\n                // Placeholder for Phase 3\n            }\n        }\n\n        Ok(!app.state.should_exit)\n    }\n}\n\nimpl Default for EventLoop {\n    fn default() -> Self {\n        Self::new()\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn test_event_loop_creation() {\n        let event_loop = EventLoop::new();\n        assert!(!event_loop.shutdown);\n    }\n\n    #[test]\n    fn test_shutdown_flag() {\n        let mut event_loop = EventLoop::new();\n        assert!(!event_loop.is_shutdown());\n\n        event_loop.shutdown();\n        assert!(event_loop.is_shutdown());\n    }\n\n    // Note: Cannot easily test run() in unit tests since it requires a TTY.\n    // Integration tests should verify:\n    // 1. Tick events are generated at 150ms intervals\n    // 2. Keyboard events are properly filtered to Press only\n    // 3. Rapid keypresses are batched\n    // 4. SIGINT triggers graceful shutdown\n    // 5. Resize events are propagated correctly\n}\n"
  },
  {
    "path": "crates/atuin-ai/src/tui/mod.rs",
    "content": "pub mod app;\npub mod component;\npub mod components;\npub mod event;\n#[cfg(unix)]\npub mod popup;\npub mod render;\npub mod spinner;\npub mod state;\npub mod terminal;\npub mod view_model;\n\npub use app::App;\npub use event::{AppEvent, EventLoop};\npub use render::{RenderContext, calculate_needed_height, markdown_to_spans};\npub use state::{AppMode, AppState, ConversationEvent, ExitAction};\npub use terminal::{TerminalGuard, install_panic_hook};\npub use view_model::{Block, Blocks, Content};\n"
  },
  {
    "path": "crates/atuin-ai/src/tui/popup.rs",
    "content": "use ratatui::layout::Rect;\n\n/// Maximum popup height (lines). Keeps context visible around the popup.\nconst MAX_POPUP_HEIGHT: u16 = 24;\n\n/// Minimum usable popup height.\nconst MIN_POPUP_HEIGHT: u16 = 5;\n\n/// Initial popup height — just enough for input + a small response.\nconst INITIAL_POPUP_HEIGHT: u16 = 5;\n\n/// Margin around the card in popup mode.\npub(crate) const POPUP_MARGIN: u16 = 0;\n\n/// Screen state captured from atuin-hex's screen server.\npub struct SavedScreen {\n    #[allow(dead_code)]\n    pub rows: u16,\n    #[allow(dead_code)]\n    pub cols: u16,\n    pub cursor_row: u16,\n    pub cursor_col: u16,\n    /// Pre-formatted ANSI bytes for each screen row, ready to write to stdout.\n    pub rows_data: Vec<Vec<u8>>,\n}\n\n/// Popup mode state: saved screen + computed placement.\npub struct PopupState {\n    pub saved_screen: SavedScreen,\n    /// Maximum rect computed from placement (the ceiling for growth).\n    pub max_rect: Rect,\n    /// Current rect — starts small, grows as content arrives.\n    pub current_rect: Rect,\n    pub scroll_offset: u16,\n    /// True when the popup renders above the cursor (input at bottom of card).\n    pub render_above: bool,\n}\n\nimpl PopupState {\n    /// Resize the popup to fit `needed` lines of content.\n    ///\n    /// Grows or shrinks the popup as needed (clamped to max_rect / INITIAL_POPUP_HEIGHT).\n    /// When growing, clears the new rect area. When shrinking, restores freed rows\n    /// from the saved screen data.\n    ///\n    /// Returns `Some(new_rect)` if the size changed (caller must resize terminal),\n    /// or `None` if no change is needed.\n    pub fn fit_to(&mut self, needed: u16) -> Option<Rect> {\n        let new_height = needed.clamp(INITIAL_POPUP_HEIGHT, self.max_rect.height);\n        if new_height == self.current_rect.height {\n            return None;\n        }\n\n        let old_rect = self.current_rect;\n        let growing = new_height > old_rect.height;\n\n        if self.render_above {\n            let new_y = self.max_rect.y + self.max_rect.height - new_height;\n            self.current_rect = Rect::new(old_rect.x, new_y, old_rect.width, new_height);\n        } else {\n            self.current_rect = Rect::new(old_rect.x, old_rect.y, old_rect.width, new_height);\n        }\n\n        if growing {\n            // Clear the entire new rect so the new Terminal doesn't leave\n            // ghost content from the old card.\n            self.clear_rows(\n                self.current_rect.y,\n                self.current_rect.y + self.current_rect.height,\n            );\n        } else {\n            // Shrinking: restore freed rows from saved screen data, then\n            // clear the new (smaller) rect for the re-rendered card.\n            self.restore_rows(&old_rect);\n            self.clear_rows(\n                self.current_rect.y,\n                self.current_rect.y + self.current_rect.height,\n            );\n        }\n\n        Some(self.current_rect)\n    }\n\n    /// Clear a range of terminal rows within the popup width.\n    fn clear_rows(&self, from_row: u16, to_row: u16) {\n        use crossterm::cursor::MoveTo;\n        use crossterm::execute;\n        use crossterm::style::{Attribute, SetAttribute};\n        use std::io::{Write, stdout};\n\n        let mut out = stdout();\n        for row in from_row..to_row {\n            let _ = execute!(\n                out,\n                MoveTo(self.current_rect.x, row),\n                SetAttribute(Attribute::Reset)\n            );\n            let _ = write!(\n                out,\n                \"{:width$}\",\n                \"\",\n                width = self.current_rect.width as usize\n            );\n        }\n        let _ = out.flush();\n    }\n\n    /// Restore rows that were freed by shrinking — the rows in old_rect\n    /// that are no longer covered by current_rect.\n    fn restore_rows(&self, old_rect: &Rect) {\n        use crossterm::cursor::MoveTo;\n        use crossterm::execute;\n        use crossterm::style::{Attribute, SetAttribute};\n        use std::io::{Write, stdout};\n\n        let mut out = stdout();\n\n        // Determine which rows are freed\n        let (freed_start, freed_end) = if self.render_above {\n            // Shrinking from above: freed rows are at the old top\n            (old_rect.y, self.current_rect.y)\n        } else {\n            // Shrinking from below: freed rows are at the old bottom\n            (\n                self.current_rect.y + self.current_rect.height,\n                old_rect.y + old_rect.height,\n            )\n        };\n\n        for row in freed_start..freed_end {\n            let source_row = (row + self.scroll_offset) as usize;\n\n            // Clear the popup region\n            let _ = execute!(out, MoveTo(old_rect.x, row), SetAttribute(Attribute::Reset),);\n            let _ = write!(out, \"{:width$}\", \"\", width = old_rect.width as usize);\n\n            // Write back saved row data from column 0\n            let _ = execute!(out, MoveTo(0, row));\n            if let Some(row_bytes) = self.saved_screen.rows_data.get(source_row) {\n                let _ = out.write_all(row_bytes);\n            }\n        }\n        let _ = out.flush();\n    }\n}\n\n/// Try to set up popup overlay mode.\n///\n/// Checks for `ATUIN_HEX_SOCKET`, fetches screen state, computes placement,\n/// and scrolls the terminal if needed. Returns `None` if popup mode is not\n/// available (no socket, fetch failed, etc.), in which case the caller should\n/// fall back to inline mode.\npub fn try_setup_popup() -> Option<PopupState> {\n    use std::io::Write;\n\n    let socket_path = std::env::var(\"ATUIN_HEX_SOCKET\").ok()?;\n    let saved = fetch_screen_state(&socket_path)?;\n\n    let (term_cols, term_rows) = crossterm::terminal::size().unwrap_or((saved.cols, saved.rows));\n    // Full-width popup with margin for visual separation\n    let popup_width = term_cols;\n    let (rect, scroll, render_above) = compute_popup_placement(\n        saved.cursor_row,\n        saved.cursor_col,\n        term_rows,\n        term_cols,\n        popup_width,\n    );\n\n    // Scroll terminal up if needed to make room for the popup\n    if scroll > 0 {\n        let mut stdout = std::io::stdout();\n        let _ = crossterm::execute!(stdout, crossterm::cursor::MoveTo(0, term_rows - 1));\n        for _ in 0..scroll {\n            let _ = writeln!(stdout);\n        }\n        let _ = stdout.flush();\n    }\n\n    // Start with a small rect that grows as content arrives\n    let initial_height = INITIAL_POPUP_HEIGHT.min(rect.height);\n    let current_rect = if render_above {\n        // Anchor at the bottom of max_rect (near cursor), grow upward\n        Rect::new(\n            rect.x,\n            rect.y + rect.height - initial_height,\n            rect.width,\n            initial_height,\n        )\n    } else {\n        // Anchor at the top of max_rect (near cursor), grow downward\n        Rect::new(rect.x, rect.y, rect.width, initial_height)\n    };\n\n    Some(PopupState {\n        saved_screen: saved,\n        max_rect: rect,\n        current_rect,\n        scroll_offset: scroll,\n        render_above,\n    })\n}\n\n/// Restore the screen area that was covered by the popup.\n///\n/// Clears the popup region, then writes pre-formatted per-row ANSI bytes from\n/// column 0 to correctly restore wide characters, colors, and all attributes.\npub fn restore(state: &PopupState) {\n    use crossterm::cursor::MoveTo;\n    use crossterm::execute;\n    use crossterm::style::{Attribute, SetAttribute};\n    use std::io::{Write, stdout};\n\n    let saved = &state.saved_screen;\n    let popup_rect = state.current_rect;\n    let scroll_offset = state.scroll_offset;\n\n    let mut stdout = stdout();\n\n    for dy in 0..popup_rect.height {\n        let target_row = popup_rect.y + dy;\n        let source_row = (target_row + scroll_offset) as usize;\n\n        // Clear only the popup region with spaces\n        let _ = execute!(\n            stdout,\n            MoveTo(popup_rect.x, target_row),\n            SetAttribute(Attribute::Reset),\n        );\n        let _ = write!(stdout, \"{:width$}\", \"\", width = popup_rect.width as usize);\n\n        // Write back full row ANSI data from column 0\n        let _ = execute!(stdout, MoveTo(0, target_row));\n        if let Some(row_bytes) = saved.rows_data.get(source_row) {\n            let _ = stdout.write_all(row_bytes);\n        }\n    }\n\n    // Restore cursor position (adjusted for any scrolling)\n    let _ = execute!(\n        stdout,\n        MoveTo(\n            saved.cursor_col,\n            saved.cursor_row.saturating_sub(scroll_offset)\n        )\n    );\n    let _ = stdout.flush();\n}\n\n/// Connect to atuin-hex's Unix socket and fetch the current screen state.\n///\n/// The wire format is:\n/// ```text\n/// [rows: u16 BE][cols: u16 BE][cursor_row: u16 BE][cursor_col: u16 BE]\n/// [row_0_len: u32 BE][row_0_bytes...]\n/// [row_1_len: u32 BE][row_1_bytes...]\n/// ...\n/// ```\nfn fetch_screen_state(socket_path: &str) -> Option<SavedScreen> {\n    use std::io::Read;\n    use std::os::unix::net::UnixStream;\n    use std::time::Duration;\n\n    let mut stream = UnixStream::connect(socket_path).ok()?;\n    stream.set_read_timeout(Some(Duration::from_secs(2))).ok()?;\n\n    let mut data = Vec::new();\n    stream.read_to_end(&mut data).ok()?;\n\n    if data.len() < 8 {\n        return None;\n    }\n\n    let rows = u16::from_be_bytes([data[0], data[1]]);\n    let cols = u16::from_be_bytes([data[2], data[3]]);\n    let cursor_row = u16::from_be_bytes([data[4], data[5]]);\n    let cursor_col = u16::from_be_bytes([data[6], data[7]]);\n\n    let mut rows_data = Vec::with_capacity(rows as usize);\n    let mut offset = 8;\n    while offset + 4 <= data.len() {\n        let row_len = u32::from_be_bytes([\n            data[offset],\n            data[offset + 1],\n            data[offset + 2],\n            data[offset + 3],\n        ]) as usize;\n        offset += 4;\n        if offset + row_len > data.len() {\n            break;\n        }\n        rows_data.push(data[offset..offset + row_len].to_vec());\n        offset += row_len;\n    }\n\n    Some(SavedScreen {\n        rows,\n        cols,\n        cursor_row,\n        cursor_col,\n        rows_data,\n    })\n}\n\n/// Compute popup placement for the AI card.\n///\n/// Positions the popup near the cursor: below if there's room, above otherwise.\n/// Uses a capped height (MAX_POPUP_HEIGHT) so the popup doesn't fill the screen.\n///\n/// Returns `(popup_rect, scroll_offset, render_above)`:\n/// - `render_above`: true when popup is above cursor (input should be at bottom)\n/// - `scroll_offset`: lines the caller should scroll the terminal up\nfn compute_popup_placement(\n    cursor_row: u16,\n    cursor_col: u16,\n    term_rows: u16,\n    term_cols: u16,\n    card_width: u16,\n) -> (Rect, u16, bool) {\n    // Horizontal: anchor card near cursor, clamp to screen\n    let popup_w = card_width.min(term_cols);\n    let preferred_x = cursor_col.saturating_sub(2);\n    let max_x = term_cols.saturating_sub(popup_w);\n    let popup_x = preferred_x.min(max_x);\n\n    // Vertical: use a reasonable height, not the full terminal\n    let max_h = MAX_POPUP_HEIGHT\n        .min(term_rows.saturating_sub(2))\n        .max(MIN_POPUP_HEIGHT);\n    let space_above = cursor_row;\n    let space_below = term_rows.saturating_sub(cursor_row);\n\n    if max_h <= space_below {\n        // Fits below cursor — input at top (close to prompt)\n        let popup_y = cursor_row;\n        (Rect::new(popup_x, popup_y, popup_w, max_h), 0, false)\n    } else if max_h <= space_above {\n        // Fits above cursor — input at bottom (close to prompt)\n        let popup_y = cursor_row.saturating_sub(max_h);\n        (Rect::new(popup_x, popup_y, popup_w, max_h), 0, true)\n    } else {\n        // Neither side fits fully — use whichever side has more space,\n        // scrolling the terminal if needed to reach MIN_POPUP_HEIGHT.\n        let render_above = space_above > space_below;\n        let available = if render_above {\n            space_above\n        } else {\n            space_below\n        };\n        let h = available.max(MIN_POPUP_HEIGHT).min(max_h);\n        let scroll = h.saturating_sub(available);\n        let popup_y = if render_above {\n            cursor_row.saturating_sub(h + scroll)\n        } else {\n            cursor_row.saturating_sub(scroll)\n        };\n        (\n            Rect::new(popup_x, popup_y, popup_w, h),\n            scroll,\n            render_above,\n        )\n    }\n}\n"
  },
  {
    "path": "crates/atuin-ai/src/tui/render.rs",
    "content": "use atuin_client::theme::{Meaning, Theme};\nuse pulldown_cmark::{Event, Parser, Tag, TagEnd};\nuse ratatui::{\n    Frame,\n    backend::FromCrossterm,\n    layout::{Alignment, Rect},\n    style::{Modifier, Style},\n    text::{Line, Span},\n    widgets::{Block as RatatuiBlock, Borders, Padding},\n};\n\nuse super::component::Component;\npub use super::component::RenderContext;\nuse super::components::build_component_tree;\nuse super::spinner::active_frame;\nuse super::state::AppState;\nuse super::view_model::Blocks;\n\n/// Fixed card width for the TUI\npub(crate) const CARD_WIDTH: u16 = 64;\n\n/// Calculate the height needed to render the current state.\n/// Used to dynamically resize the viewport before rendering.\n/// `card_width` is the outer card width (including borders); pass 0 to use CARD_WIDTH default.\npub fn calculate_needed_height(state: &AppState, card_width: u16) -> u16 {\n    let view = Blocks::from_state(state);\n    let w = if card_width > 0 {\n        card_width\n    } else {\n        CARD_WIDTH\n    };\n    let content_width = w.saturating_sub(4).max(1);\n\n    let items: Vec<_> = view.items.iter().collect();\n    let tree = build_component_tree(&items, w);\n\n    // Add borders (2) + top padding (1), minimum 5\n    tree.height(content_width).saturating_add(3).max(5)\n}\n\n/// Main render function: derives view model from state, then renders it\npub fn render(frame: &mut Frame, state: &AppState, ctx: &RenderContext) {\n    // PURE DERIVATION: view model is always rebuilt from state\n    let view = Blocks::from_state(state);\n\n    // Render the derived view model\n    render_view(frame, &view, ctx);\n}\n\nfn render_view(frame: &mut Frame, view: &Blocks, ctx: &RenderContext) {\n    let full_area = frame.area();\n\n    // In popup mode, the viewport is already positioned and sized for the card.\n    // Clear it to prevent background bleed-through, then inset by margin for the card.\n    let (area, card_x, desired_width) = if ctx.popup_mode {\n        #[cfg(unix)]\n        use super::popup::POPUP_MARGIN;\n        #[cfg(not(unix))]\n        const POPUP_MARGIN: u16 = 0;\n        frame.render_widget(ratatui::widgets::Clear, full_area);\n        let inset = full_area.inner(ratatui::layout::Margin {\n            horizontal: POPUP_MARGIN,\n            vertical: POPUP_MARGIN,\n        });\n        (inset, inset.x, inset.width)\n    } else {\n        let dw = CARD_WIDTH.min(full_area.width.saturating_sub(2)).max(32);\n        let max_x = full_area.x + full_area.width.saturating_sub(dw);\n        let preferred_x = full_area.x + ctx.anchor_col.saturating_sub(2);\n        (full_area, preferred_x.min(max_x), dw)\n    };\n\n    // Build ordered items list — the active content (input/LLM response)\n    // should always be closest to the cursor/prompt:\n    //   - Popup below cursor (render_above=false): reverse so active is at top\n    //   - Popup above cursor (render_above=true): normal order, active is at bottom\n    //   - Inline mode: normal order (no reversal)\n    let items: Vec<&super::view_model::Block> = if ctx.popup_mode && !ctx.render_above {\n        view.items.iter().rev().collect()\n    } else {\n        view.items.iter().collect()\n    };\n\n    // Build component tree from view model\n    let mut tree = build_component_tree(&items, desired_width);\n    let content_width = desired_width.saturating_sub(4).max(1);\n\n    let desired_height = tree.height(content_width).saturating_add(3).max(5);\n\n    // Cap card height at viewport height to prevent overflow\n    let actual_height = desired_height.min(area.height);\n\n    // Calculate scroll offset to keep the active content visible when overflowing.\n    // When render_above=false (popup below cursor), items are reversed so the active\n    // content (input/spinner) is at the top — scroll_offset stays 0 to show the top.\n    // Otherwise, scroll to show the bottom where the active content lives.\n    tree.scroll_offset = if ctx.popup_mode && !ctx.render_above {\n        0\n    } else {\n        desired_height.saturating_sub(actual_height)\n    };\n\n    let card = Rect {\n        x: card_x,\n        y: area.y,\n        width: desired_width,\n        height: actual_height,\n    };\n\n    // Get title from first block in ORIGINAL order (always the input block)\n    let title = view\n        .items\n        .first()\n        .and_then(|b| b.title.as_deref())\n        .unwrap_or(\"Describe the command you'd like to generate:\");\n\n    // Create bordered frame\n    // Padding: left=1, right=1, top=1, bottom=0 (blocks have trailing blanks)\n    let mut outer_block = RatatuiBlock::default()\n        .borders(Borders::ALL)\n        .title(title)\n        .title_top(Line::from(\"atuin\").alignment(Alignment::Right))\n        .title_bottom(Line::from(view.footer).alignment(Alignment::Right))\n        .padding(Padding::new(1, 1, 1, 0));\n\n    // Status bar: transient status on the bottom border, left-aligned\n    if let Some(ref sb) = view.status_bar {\n        let style = Style::from_crossterm(ctx.theme.as_style(Meaning::Annotation));\n        let spinner = active_frame(sb.frame);\n        let status_text = format!(\" {} {} \", spinner, sb.text);\n        outer_block = outer_block\n            .title_bottom(Line::from(Span::styled(status_text, style)).alignment(Alignment::Left));\n    }\n\n    let inner_area = outer_block.inner(card);\n    frame.render_widget(outer_block, card);\n\n    // Render the component tree\n    tree.render(frame, inner_area, ctx);\n}\n\n/// Convert markdown to styled spans\npub fn markdown_to_spans<'a>(text: &'a str, theme: &'a Theme) -> Vec<Line<'a>> {\n    let parser = Parser::new(text);\n    let mut lines: Vec<Vec<Span<'a>>> = vec![Vec::new()];\n    let mut current_line = 0;\n\n    let base_style = Style::from_crossterm(theme.as_style(Meaning::Base));\n    let code_style = Style::from_crossterm(theme.as_style(Meaning::Important));\n    let mut style_stack: Vec<Style> = vec![base_style];\n    let mut in_code_block = false;\n\n    for event in parser {\n        match event {\n            Event::Start(Tag::Strong) => {\n                let bold_style = style_stack\n                    .last()\n                    .copied()\n                    .unwrap_or(base_style)\n                    .add_modifier(Modifier::BOLD);\n                style_stack.push(bold_style);\n            }\n            Event::End(TagEnd::Strong) => {\n                style_stack.pop();\n            }\n            Event::Start(Tag::Emphasis) => {\n                let underline_style = style_stack\n                    .last()\n                    .copied()\n                    .unwrap_or(base_style)\n                    .add_modifier(Modifier::UNDERLINED);\n                style_stack.push(underline_style);\n            }\n            Event::End(TagEnd::Emphasis) => {\n                style_stack.pop();\n            }\n            Event::Start(Tag::CodeBlock(_)) => {\n                in_code_block = true;\n                // Start new line for code block\n                if !lines[current_line].is_empty() {\n                    current_line += 1;\n                    lines.push(Vec::new());\n                }\n            }\n            Event::End(TagEnd::CodeBlock) => {\n                in_code_block = false;\n                // Ensure blank line after code block\n                if !lines[current_line].is_empty() {\n                    current_line += 1;\n                    lines.push(Vec::new());\n                }\n            }\n            Event::Code(code) => {\n                lines[current_line].push(Span::styled(format!(\"`{}`\", code), code_style));\n            }\n            Event::Text(text) => {\n                let current_style = if in_code_block {\n                    // Use Important style for code block content\n                    code_style\n                } else {\n                    style_stack.last().copied().unwrap_or(base_style)\n                };\n                let parts: Vec<&str> = text.split('\\n').collect();\n                for (i, part) in parts.iter().enumerate() {\n                    if i > 0 {\n                        current_line += 1;\n                        lines.push(Vec::new());\n                    }\n                    if !part.is_empty() {\n                        lines[current_line].push(Span::styled(part.to_string(), current_style));\n                    }\n                }\n            }\n            Event::SoftBreak => {\n                let current_style = style_stack.last().copied().unwrap_or(base_style);\n                lines[current_line].push(Span::styled(\" \", current_style));\n            }\n            Event::HardBreak => {\n                current_line += 1;\n                lines.push(Vec::new());\n            }\n            Event::Start(Tag::Paragraph) => {\n                if current_line > 0 || !lines[0].is_empty() {\n                    current_line += 1;\n                    lines.push(Vec::new());\n                }\n            }\n            Event::End(TagEnd::Paragraph) => {}\n            _ => {}\n        }\n    }\n\n    lines.into_iter().map(Line::from).collect()\n}\n"
  },
  {
    "path": "crates/atuin-ai/src/tui/spinner.rs",
    "content": "//! Spinner styles and configuration for TUI animations\n//!\n//! To experiment with different spinners, change `ACTIVE_SPINNER` below.\n\nuse std::time::Duration;\n\n/// Active spinner style - change this to experiment with different styles\npub const ACTIVE_SPINNER: SpinnerStyle = SpinnerStyle::Dots;\n\n/// Spinner style definitions\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\npub enum SpinnerStyle {\n    /// Classic ASCII line spinner: / - \\ |\n    Line,\n    /// Braille dots pattern\n    Dots,\n    /// Growing/shrinking dots\n    Pulse,\n    /// Simple arrow rotation\n    Arrow,\n    /// Block building\n    Block,\n}\n\nimpl SpinnerStyle {\n    /// Get the frames for this spinner style\n    pub const fn frames(&self) -> &'static [&'static str] {\n        match self {\n            SpinnerStyle::Line => &[\"/\", \"-\", \"\\\\\", \"|\"],\n            SpinnerStyle::Dots => &[\"⠋\", \"⠙\", \"⠹\", \"⠸\", \"⠼\", \"⠴\", \"⠦\", \"⠧\", \"⠇\", \"⠏\"],\n            SpinnerStyle::Pulse => &[\"·\", \"•\", \"●\", \"•\"],\n            SpinnerStyle::Arrow => &[\"←\", \"↖\", \"↑\", \"↗\", \"→\", \"↘\", \"↓\", \"↙\"],\n            SpinnerStyle::Block => &[\n                \"▏\", \"▎\", \"▍\", \"▌\", \"▋\", \"▊\", \"▉\", \"█\", \"▉\", \"▊\", \"▋\", \"▌\", \"▍\", \"▎\", \"▏\",\n            ],\n        }\n    }\n\n    /// Get the recommended tick interval for this spinner style\n    /// Faster spinners need shorter intervals to look smooth\n    pub const fn tick_interval(&self) -> Duration {\n        match self {\n            SpinnerStyle::Line => Duration::from_millis(150),\n            SpinnerStyle::Dots => Duration::from_millis(80),\n            SpinnerStyle::Pulse => Duration::from_millis(200),\n            SpinnerStyle::Arrow => Duration::from_millis(100),\n            SpinnerStyle::Block => Duration::from_millis(80),\n        }\n    }\n\n    /// Get the frame at the given index (wraps around)\n    pub fn frame_at(&self, index: usize) -> &'static str {\n        let frames = self.frames();\n        frames[index % frames.len()]\n    }\n\n    /// Get the number of frames in this spinner\n    pub fn frame_count(&self) -> usize {\n        self.frames().len()\n    }\n}\n\n/// Get the active spinner's frame at the given index\npub fn active_frame(index: usize) -> &'static str {\n    ACTIVE_SPINNER.frame_at(index)\n}\n\n/// Get the active spinner's tick interval\npub fn active_tick_interval() -> Duration {\n    ACTIVE_SPINNER.tick_interval()\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn test_frame_wrapping() {\n        let style = SpinnerStyle::Line;\n        assert_eq!(style.frame_at(0), \"/\");\n        assert_eq!(style.frame_at(4), \"/\"); // wraps\n        assert_eq!(style.frame_at(5), \"-\");\n    }\n\n    #[test]\n    fn test_all_styles_have_frames() {\n        let styles = [\n            SpinnerStyle::Line,\n            SpinnerStyle::Dots,\n            SpinnerStyle::Pulse,\n            SpinnerStyle::Arrow,\n            SpinnerStyle::Block,\n        ];\n        for style in styles {\n            assert!(!style.frames().is_empty());\n            assert!(style.tick_interval().as_millis() > 0);\n        }\n    }\n}\n"
  },
  {
    "path": "crates/atuin-ai/src/tui/state.rs",
    "content": "//! Domain state types for the TUI application\n//!\n//! This module contains the core state types that represent the application's\n//! domain model. Conversation events match the API protocol format.\n\nuse std::time::Instant;\nuse tui_textarea::TextArea;\n\nuse super::spinner::{ACTIVE_SPINNER, active_tick_interval};\n\n/// Streaming status indicators from server\n#[derive(Debug, Clone, PartialEq, Eq)]\npub enum StreamingStatus {\n    Processing,\n    Searching,\n    Thinking,\n    WaitingForTools,\n}\n\nimpl StreamingStatus {\n    pub fn from_status_str(s: &str) -> Self {\n        match s {\n            \"processing\" => Self::Processing,\n            \"searching\" => Self::Searching,\n            \"waiting_for_tools\" => Self::WaitingForTools,\n            _ => Self::Thinking, // Default to thinking for \"thinking\" and unknown\n        }\n    }\n\n    pub fn display_text(&self) -> &'static str {\n        match self {\n            Self::Processing => \"Processing...\",\n            Self::Searching => \"Searching...\",\n            Self::Thinking => \"Thinking...\",\n            Self::WaitingForTools => \"Waiting for tools...\",\n        }\n    }\n}\n\n/// Conversation event types matching the API protocol\n#[derive(Debug, Clone)]\npub enum ConversationEvent {\n    /// User message (what the user typed)\n    UserMessage { content: String },\n    /// Text content from assistant (streamed or complete)\n    Text { content: String },\n    /// Tool call from assistant\n    ToolCall {\n        id: String,\n        name: String,\n        input: serde_json::Value,\n    },\n    /// Tool result (usually from server-side execution)\n    ToolResult {\n        tool_use_id: String,\n        content: String,\n        is_error: bool,\n    },\n}\n\nimpl ConversationEvent {\n    /// Convert to JSON for API calls\n    pub fn to_json(&self) -> serde_json::Value {\n        match self {\n            ConversationEvent::UserMessage { content } => serde_json::json!({\n                \"type\": \"user_message\",\n                \"content\": content\n            }),\n            ConversationEvent::Text { content } => serde_json::json!({\n                \"type\": \"text\",\n                \"content\": content\n            }),\n            ConversationEvent::ToolCall { id, name, input } => serde_json::json!({\n                \"type\": \"tool_call\",\n                \"id\": id,\n                \"name\": name,\n                \"input\": input\n            }),\n            ConversationEvent::ToolResult {\n                tool_use_id,\n                content,\n                is_error,\n            } => serde_json::json!({\n                \"type\": \"tool_result\",\n                \"tool_use_id\": tool_use_id,\n                \"content\": content,\n                \"is_error\": is_error\n            }),\n        }\n    }\n\n    /// Extract command from a suggest_command tool call\n    pub fn as_command(&self) -> Option<&str> {\n        if let ConversationEvent::ToolCall { name, input, .. } = self\n            && name == \"suggest_command\"\n        {\n            // command can be null for pure conversational turns\n            return input.get(\"command\").and_then(|v| v.as_str());\n        }\n        None\n    }\n}\n\n#[derive(Debug, Clone, PartialEq, Eq)]\npub enum AppMode {\n    /// User is typing input\n    Input,\n    /// Waiting for generation (showing spinner)\n    Generating,\n    /// Streaming SSE response\n    Streaming,\n    /// Reviewing generated command\n    Review,\n    /// Error state, can retry\n    Error,\n}\n\n#[derive(Debug, Clone, PartialEq, Eq)]\npub enum ExitAction {\n    /// Run the command\n    Execute(String),\n    /// Insert command without running\n    Insert(String),\n    /// User canceled\n    Cancel,\n}\n\n/// Application state - the domain model\n///\n/// Conversation is stored as a sequence of events matching the API protocol.\n/// The view model is derived from this state via `Blocks::from_state()`.\npub struct AppState {\n    /// Current application mode\n    pub mode: AppMode,\n    /// Conversation events (source of truth, matches API protocol)\n    pub events: Vec<ConversationEvent>,\n    /// Text being streamed (accumulated, flushed to Text event on completion)\n    pub streaming_text: String,\n    /// Active text input (uses tui-textarea for proper cursor handling)\n    pub textarea: TextArea<'static>,\n    /// Current error message (renders at end of blocks)\n    pub error: Option<String>,\n    /// Whether app should exit\n    pub should_exit: bool,\n    /// Exit action (set when exiting)\n    pub exit_action: Option<ExitAction>,\n    /// Session ID from server (store after first response, send on subsequent)\n    pub session_id: Option<String>,\n    /// Current streaming status (for spinner text)\n    pub streaming_status: Option<StreamingStatus>,\n    /// Whether current turn was interrupted by user\n    pub was_interrupted: bool,\n    /// Spinner animation state\n    pub spinner_frame: usize,\n    /// When spinner frame last advanced (for timing control)\n    pub last_spinner_tick: Instant,\n    /// When streaming started (for spinner delay)\n    pub streaming_started: Option<Instant>,\n    /// True when user has pressed Enter once on a dangerous command\n    pub confirmation_pending: bool,\n}\n\n/// Create a TextArea with our preferred configuration\nfn create_textarea() -> TextArea<'static> {\n    let mut textarea = TextArea::default();\n    // Disable underline on cursor line - it's distracting\n    textarea.set_cursor_line_style(ratatui::style::Style::default());\n    // Enable word wrapping\n    textarea.set_wrap_mode(tui_textarea::WrapMode::Word);\n    textarea\n}\n\nimpl AppState {\n    pub fn new() -> Self {\n        Self {\n            mode: AppMode::Input,\n            events: Vec::new(),\n            streaming_text: String::new(),\n            textarea: create_textarea(),\n            error: None,\n            should_exit: false,\n            exit_action: None,\n            session_id: None,\n            streaming_status: None,\n            was_interrupted: false,\n            spinner_frame: 0,\n            last_spinner_tick: Instant::now(),\n            streaming_started: None,\n            confirmation_pending: false,\n        }\n    }\n\n    /// Get the current input text\n    pub fn input(&self) -> String {\n        self.textarea.lines().join(\"\\n\")\n    }\n\n    /// Check if input is empty\n    pub fn input_is_empty(&self) -> bool {\n        self.textarea.is_empty()\n    }\n\n    /// Clear the input\n    pub fn clear_input(&mut self) {\n        self.textarea = create_textarea();\n    }\n\n    /// Convert conversation events to Claude API message format\n    /// Groups consecutive tool calls, handles role alternation\n    pub fn events_to_messages(&self) -> Vec<serde_json::Value> {\n        let mut messages = Vec::new();\n        let mut i = 0;\n        let events = &self.events;\n\n        while i < events.len() {\n            match &events[i] {\n                ConversationEvent::UserMessage { content } => {\n                    messages.push(serde_json::json!({\n                        \"role\": \"user\",\n                        \"content\": content\n                    }));\n                    i += 1;\n                }\n                ConversationEvent::Text { content } => {\n                    messages.push(serde_json::json!({\n                        \"role\": \"assistant\",\n                        \"content\": content\n                    }));\n                    i += 1;\n                }\n                ConversationEvent::ToolCall { .. } => {\n                    // Group consecutive tool calls into single assistant message\n                    let mut tool_uses = Vec::new();\n                    while i < events.len() {\n                        if let ConversationEvent::ToolCall { id, name, input } = &events[i] {\n                            tool_uses.push(serde_json::json!({\n                                \"type\": \"tool_use\",\n                                \"id\": id,\n                                \"name\": name,\n                                \"input\": input\n                            }));\n                            i += 1;\n                        } else {\n                            break;\n                        }\n                    }\n                    messages.push(serde_json::json!({\n                        \"role\": \"assistant\",\n                        \"content\": tool_uses\n                    }));\n                }\n                ConversationEvent::ToolResult {\n                    tool_use_id,\n                    content,\n                    is_error,\n                } => {\n                    messages.push(serde_json::json!({\n                        \"role\": \"user\",\n                        \"content\": [{\n                            \"type\": \"tool_result\",\n                            \"tool_use_id\": tool_use_id,\n                            \"content\": content,\n                            \"is_error\": is_error\n                        }]\n                    }));\n                    i += 1;\n                }\n            }\n        }\n\n        messages\n    }\n\n    // ===== Generation lifecycle methods =====\n\n    /// Start generating from current input\n    pub fn start_generating(&mut self) {\n        // Add user message event\n        self.events.push(ConversationEvent::UserMessage {\n            content: self.input(),\n        });\n\n        // Clear input, switch mode\n        self.clear_input();\n        self.mode = AppMode::Generating;\n    }\n\n    /// Generation complete with command (legacy method, kept for compatibility)\n    pub fn generation_complete(\n        &mut self,\n        command: String,\n        explanation: Option<String>,\n        dangerous: bool,\n        warnings: Vec<String>,\n    ) {\n        // Add explanation as text event if present\n        if let Some(ref exp) = explanation {\n            self.events.push(ConversationEvent::Text {\n                content: exp.clone(),\n            });\n        }\n\n        // Add tool_call event for suggest_command\n        let tool_id = format!(\"gen_{}\", uuid::Uuid::new_v4().simple());\n        let mut tool_input = serde_json::json!({\n            \"command\": command,\n            \"conversation_only\": false,\n            \"confidence\": \"high\"\n        });\n        if let Some(ref exp) = explanation {\n            tool_input[\"message\"] = serde_json::json!(exp);\n        }\n        if dangerous {\n            tool_input[\"danger\"] = serde_json::json!(\"high\");\n        }\n        if !warnings.is_empty() {\n            tool_input[\"warning\"] = serde_json::json!(warnings.join(\"; \"));\n        }\n\n        self.events.push(ConversationEvent::ToolCall {\n            id: tool_id,\n            name: \"suggest_command\".to_string(),\n            input: tool_input,\n        });\n\n        self.mode = AppMode::Review;\n    }\n\n    /// Generation error occurred\n    pub fn generation_error(&mut self, error: String) {\n        self.error = Some(error);\n        self.mode = AppMode::Error;\n    }\n\n    /// Cancel during generation\n    pub fn cancel_generation(&mut self) {\n        // Remove the last user message since generation was cancelled\n        if let Some(ConversationEvent::UserMessage { .. }) = self.events.last() {\n            self.events.pop();\n        }\n        self.mode = AppMode::Input;\n        self.clear_input();\n    }\n\n    // ===== Streaming lifecycle methods =====\n\n    /// Start streaming response\n    pub fn start_streaming(&mut self) {\n        self.streaming_text.clear();\n        self.streaming_status = None;\n        self.was_interrupted = false;\n        self.streaming_started = Some(Instant::now());\n        self.mode = AppMode::Streaming;\n    }\n\n    /// Store session ID from server response\n    pub fn store_session_id(&mut self, session_id: String) {\n        self.session_id = Some(session_id);\n    }\n\n    /// Update streaming status from SSE event\n    pub fn update_streaming_status(&mut self, status: &str) {\n        self.streaming_status = Some(StreamingStatus::from_status_str(status));\n    }\n\n    /// Cancel streaming with context preservation\n    pub fn cancel_streaming(&mut self) {\n        // Mark as interrupted\n        self.was_interrupted = true;\n\n        // Flush partial text with interruption marker if any\n        // Trim leading whitespace since LLM responses often start with \\n\\n\n        let content = std::mem::take(&mut self.streaming_text);\n        let trimmed = content.trim_start();\n        if !trimmed.is_empty() {\n            let interrupted_text = format!(\"{trimmed}\\n\\n[User cancelled this generation]\");\n            self.events.push(ConversationEvent::Text {\n                content: interrupted_text,\n            });\n        }\n\n        // Clear status and return to input\n        self.streaming_status = None;\n        self.confirmation_pending = false;\n        self.mode = AppMode::Input;\n    }\n\n    /// Append text chunk during streaming\n    /// Trims leading whitespace from the first chunk(s) since LLM responses often start with \\n\\n\n    pub fn append_streaming_text(&mut self, chunk: &str) {\n        if self.streaming_text.is_empty() {\n            // First chunk(s): trim leading whitespace\n            let trimmed = chunk.trim_start();\n            if !trimmed.is_empty() {\n                self.streaming_text.push_str(trimmed);\n            }\n        } else {\n            // Subsequent chunks: append as-is\n            self.streaming_text.push_str(chunk);\n        }\n    }\n\n    /// Add a tool call event during streaming\n    /// Flushes any pending streaming text first to maintain correct event order\n    /// For suggest_command, also transitions to Review mode since that ends the LLM turn\n    pub fn add_tool_call(&mut self, id: String, name: String, input: serde_json::Value) {\n        // Flush streaming text before adding tool call to maintain correct order\n        let content = std::mem::take(&mut self.streaming_text);\n        let trimmed = content.trim_start();\n        if !trimmed.is_empty() {\n            self.events.push(ConversationEvent::Text {\n                content: trimmed.to_string(),\n            });\n        }\n\n        // suggest_command marks the end of the LLM turn - transition to Review\n        let is_suggest_command = name == \"suggest_command\";\n\n        self.events\n            .push(ConversationEvent::ToolCall { id, name, input });\n\n        if is_suggest_command {\n            self.streaming_status = None;\n            self.streaming_started = None;\n            self.mode = AppMode::Review;\n        }\n    }\n\n    /// Add a tool result event during streaming\n    pub fn add_tool_result(&mut self, tool_use_id: String, content: String, is_error: bool) {\n        self.events.push(ConversationEvent::ToolResult {\n            tool_use_id,\n            content,\n            is_error,\n        });\n    }\n\n    /// Finalize streaming - flush accumulated text to event\n    pub fn finalize_streaming(&mut self) {\n        // Flush streaming text to a Text event if non-empty\n        // Trim leading whitespace since LLM responses often start with \\n\\n\n        let content = std::mem::take(&mut self.streaming_text);\n        let trimmed = content.trim_start();\n        if !trimmed.is_empty() {\n            self.events.push(ConversationEvent::Text {\n                content: trimmed.to_string(),\n            });\n        }\n        self.streaming_status = None;\n        self.streaming_started = None;\n        self.mode = AppMode::Review;\n    }\n\n    /// Streaming error\n    pub fn streaming_error(&mut self, error: String) {\n        // Discard any partial streaming text\n        self.streaming_text.clear();\n        self.streaming_started = None;\n        self.error = Some(error);\n        self.mode = AppMode::Error;\n    }\n\n    // ===== Edit mode and exit methods =====\n\n    /// Start edit mode for refinement\n    pub fn start_edit_mode(&mut self) {\n        self.confirmation_pending = false;\n        self.clear_input();\n        self.mode = AppMode::Input;\n    }\n\n    /// Exit with action\n    pub fn exit(&mut self, action: ExitAction) {\n        self.exit_action = Some(action);\n        self.should_exit = true;\n    }\n\n    /// Retry after error\n    pub fn retry(&mut self) {\n        self.error = None;\n        self.mode = AppMode::Generating;\n    }\n\n    // ===== Utility methods =====\n\n    /// Advance spinner frame if enough time has passed\n    /// Called on every event loop tick (50ms), but only advances spinner\n    /// when the active spinner's interval has elapsed\n    pub fn tick(&mut self) {\n        let interval = active_tick_interval();\n        if self.last_spinner_tick.elapsed() >= interval {\n            self.spinner_frame = (self.spinner_frame + 1) % ACTIVE_SPINNER.frame_count();\n            self.last_spinner_tick = Instant::now();\n        }\n    }\n\n    /// Get the most recent command from events\n    pub fn current_command(&self) -> Option<&str> {\n        self.events.iter().rev().find_map(|e| e.as_command())\n    }\n\n    /// Check if the most recent command suggestion is marked dangerous\n    /// Checks the `danger` field for \"high\", \"medium\", or \"med\" values\n    pub fn is_current_command_dangerous(&self) -> bool {\n        self.events\n            .iter()\n            .rev()\n            .find_map(|e| {\n                if let ConversationEvent::ToolCall { name, input, .. } = e\n                    && name == \"suggest_command\"\n                {\n                    let danger_level = input\n                        .get(\"danger\")\n                        .and_then(|v| v.as_str())\n                        .unwrap_or(\"low\");\n                    return Some(\n                        danger_level == \"high\" || danger_level == \"medium\" || danger_level == \"med\",\n                    );\n                }\n                None\n            })\n            .unwrap_or(false)\n    }\n}\n\nimpl Default for AppState {\n    fn default() -> Self {\n        Self::new()\n    }\n}\n"
  },
  {
    "path": "crates/atuin-ai/src/tui/terminal.rs",
    "content": "use crossterm::{\n    cursor,\n    terminal::{disable_raw_mode, enable_raw_mode},\n};\nuse eyre::{Context, Result, bail};\nuse ratatui::{Terminal, TerminalOptions, Viewport, backend::CrosstermBackend, layout::Rect};\nuse std::io::{IsTerminal, Stdout, stdout};\n\n/// Install a panic hook that ensures the terminal is restored to a usable state\n/// even if the application panics.\n///\n/// This must be called before creating the TerminalGuard to ensure proper cleanup\n/// during panics. The hook will:\n/// 1. Disable raw mode (restoring normal terminal behavior)\n/// 2. Call the original panic hook to display panic information\n///\n/// # Implementation Note\n/// This satisfies TUI-07: Terminal remains usable after panic by ensuring\n/// disable_raw_mode() is called before the panic message is displayed.\npub fn install_panic_hook() {\n    let original_hook = std::panic::take_hook();\n    std::panic::set_hook(Box::new(move |panic_info| {\n        // Attempt to restore terminal - ignore errors since we're already panicking\n        let _ = disable_raw_mode();\n        // Call original hook to display panic with backtrace\n        original_hook(panic_info);\n    }));\n}\n\n/// Minimum viewport height\nconst MIN_VIEWPORT_HEIGHT: u16 = 10;\n\n/// Margin to leave below viewport for shell prompt\nconst VIEWPORT_BOTTOM_MARGIN: u16 = 2;\n\n/// Guards terminal lifecycle, ensuring proper setup and cleanup.\n///\n/// # Lifecycle\n/// - **Setup** (`new()`): Captures cursor position, enables raw mode, creates inline viewport\n/// - **Cleanup** (`Drop`): Clears terminal, disables raw mode\n///\n/// # Dynamic Viewport Sizing\n/// The viewport starts at 15 lines (enough for simple commands) and grows\n/// dynamically when content requires more space. Use `ensure_height()` before\n/// rendering to grow the viewport if needed.\n///\n/// # Safety Features\n/// - Non-TTY detection: Returns error early if stdout is not a terminal\n/// - Panic recovery: Works with `install_panic_hook()` to restore terminal after panic\n/// - Drop-based cleanup: Ensures terminal is restored on normal exit\n///\n/// # Example\n/// ```no_run\n/// use atuin_ai::tui::{install_panic_hook, TerminalGuard};\n///\n/// install_panic_hook(); // Once at program start\n/// let mut guard = TerminalGuard::new(true)?;\n/// let terminal = guard.terminal();\n/// // ... use terminal ...\n/// // Drop automatically cleans up\n/// # Ok::<(), eyre::Report>(())\n/// ```\npub struct TerminalGuard {\n    terminal: Terminal<CrosstermBackend<Stdout>>,\n    anchor_col: u16,\n    keep_output: bool,\n    viewport_height: u16,\n    popup_mode: bool,\n}\n\nimpl TerminalGuard {\n    /// Create a new TerminalGuard, initializing the terminal for inline TUI mode.\n    ///\n    /// # Arguments\n    /// * `keep_output` - If true, preserve TUI output on exit; if false, clear it\n    ///\n    /// # Process\n    /// 1. Check if stdout is a terminal (non-TTY detection)\n    /// 2. Capture cursor position for inline rendering anchor\n    /// 3. Enable raw mode for keyboard input\n    /// 4. Create terminal with inline viewport\n    ///\n    /// # Errors\n    /// - Returns error if stdout is not a terminal (e.g., piped or redirected)\n    /// - Returns error if terminal initialization fails\n    ///\n    /// # Implementation Note\n    /// Cursor position is captured BEFORE enabling raw mode because some terminals\n    /// may report position differently after raw mode is enabled.\n    pub fn new(keep_output: bool) -> Result<Self> {\n        // Non-TTY check: fail early if stdout is not a terminal\n        if !stdout().is_terminal() {\n            bail!(\n                \"atuin-ai requires a terminal (TTY) but stdout is not a terminal. \\\n                   This typically happens when output is piped or redirected.\"\n            );\n        }\n\n        // Get terminal size and calculate viewport height\n        let (_, term_height) = crossterm::terminal::size().unwrap_or((80, 24));\n        let viewport_height = term_height\n            .saturating_sub(VIEWPORT_BOTTOM_MARGIN)\n            .max(MIN_VIEWPORT_HEIGHT);\n\n        // Capture cursor position BEFORE raw mode for accurate anchor\n        let anchor_col = cursor::position().map(|(x, _)| x).unwrap_or(0);\n\n        // Enable raw mode for keyboard input\n        enable_raw_mode().context(\"failed to enable raw mode\")?;\n\n        // Create terminal with fixed viewport based on terminal size\n        let backend = CrosstermBackend::new(stdout());\n        let terminal = Terminal::with_options(\n            backend,\n            TerminalOptions {\n                viewport: Viewport::Inline(viewport_height),\n            },\n        )\n        .context(\"failed to create terminal with inline viewport\")?;\n\n        Ok(Self {\n            terminal,\n            anchor_col,\n            keep_output,\n            viewport_height,\n            popup_mode: false,\n        })\n    }\n\n    /// Create a new TerminalGuard for popup overlay mode.\n    ///\n    /// In popup mode:\n    /// - Raw mode is not managed (atuin-hex owns it)\n    /// - The viewport is a fixed rect positioned over existing terminal content\n    /// - The popup area is pre-cleared to prevent background bleed-through\n    /// - Drop does not clear the viewport or disable raw mode\n    pub fn new_popup(popup_rect: Rect, anchor_col: u16) -> Result<Self> {\n        // Pre-clear the popup area before creating the ratatui terminal.\n        // Ratatui's diff-based rendering won't write \"default\" (space) cells on\n        // the first frame because its previous buffer is also all-default. By\n        // writing spaces to the terminal now, we ensure those positions are\n        // visually blank even if ratatui skips them.\n        {\n            use crossterm::cursor::MoveTo;\n            use crossterm::execute;\n            use crossterm::style::{Attribute, SetAttribute};\n            use std::io::Write;\n\n            let mut out = stdout();\n            for row in popup_rect.y..popup_rect.y.saturating_add(popup_rect.height) {\n                let _ = execute!(\n                    out,\n                    MoveTo(popup_rect.x, row),\n                    SetAttribute(Attribute::Reset)\n                );\n                let _ = write!(out, \"{:width$}\", \"\", width = popup_rect.width as usize);\n            }\n            let _ = out.flush();\n        }\n\n        let backend = CrosstermBackend::new(stdout());\n        let terminal = Terminal::with_options(\n            backend,\n            TerminalOptions {\n                viewport: Viewport::Fixed(popup_rect),\n            },\n        )\n        .context(\"failed to create terminal with fixed viewport\")?;\n\n        Ok(Self {\n            terminal,\n            anchor_col,\n            keep_output: false,\n            viewport_height: popup_rect.height,\n            popup_mode: true,\n        })\n    }\n\n    /// Returns the current viewport height.\n    ///\n    /// The viewport is fixed at creation time based on terminal size.\n    /// Content that exceeds this height will be scrolled automatically.\n    ///\n    /// The `_needed` parameter is kept for API compatibility but ignored -\n    /// we no longer attempt to resize the viewport dynamically since that\n    /// operation can fail unpredictably with inline viewports.\n    pub fn ensure_height(&mut self, _needed: u16) -> Result<u16> {\n        Ok(self.viewport_height)\n    }\n\n    /// Get the current viewport height.\n    pub fn viewport_height(&self) -> u16 {\n        self.viewport_height\n    }\n\n    /// Get mutable reference to the underlying terminal.\n    ///\n    /// Use this to perform rendering operations.\n    pub fn terminal(&mut self) -> &mut Terminal<CrosstermBackend<Stdout>> {\n        &mut self.terminal\n    }\n\n    /// Resize the popup viewport to a new rect.\n    ///\n    /// Creates a fresh terminal with the updated Fixed viewport. The caller\n    /// is responsible for pre-clearing any newly exposed rows before calling\n    /// this (see `PopupState::grow_to`).\n    pub fn resize_popup(&mut self, new_rect: Rect) -> Result<()> {\n        self.viewport_height = new_rect.height;\n        let backend = CrosstermBackend::new(stdout());\n        self.terminal = Terminal::with_options(\n            backend,\n            TerminalOptions {\n                viewport: Viewport::Fixed(new_rect),\n            },\n        )\n        .context(\"failed to resize popup terminal\")?;\n        Ok(())\n    }\n\n    /// Get the anchor column where the inline UI should be positioned.\n    ///\n    /// This is the column position where the cursor was located when\n    /// the terminal was initialized.\n    pub fn anchor_col(&self) -> u16 {\n        self.anchor_col\n    }\n}\n\n/// Cleanup terminal state when TerminalGuard is dropped.\n///\n/// This implements TUI-08: Terminal restores correctly after normal exit.\n///\n/// # Cleanup Process\n/// 1. Conditionally clear terminal content (based on keep_output flag)\n/// 2. Disable raw mode (restore normal terminal behavior)\n///\n/// # Error Handling\n/// Errors are intentionally ignored during cleanup since:\n/// - We're already exiting and can't meaningfully handle errors\n/// - Best-effort restoration is better than panicking during Drop\n/// - The panic hook provides a second layer of safety for abnormal exits\nimpl Drop for TerminalGuard {\n    fn drop(&mut self) {\n        if self.popup_mode {\n            // Popup mode: screen restoration handled by caller before drop.\n            // Raw mode is owned by atuin-hex, don't touch it.\n            return;\n        }\n\n        // Clear terminal content only if keep_output is false - ignore errors (best-effort)\n        if !self.keep_output {\n            let _ = self.terminal.clear();\n        }\n\n        // Disable raw mode to restore normal terminal behavior - ignore errors\n        let _ = disable_raw_mode();\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn test_panic_hook_installation() {\n        // Test that panic hook can be installed without error\n        install_panic_hook();\n        // Installing again should work (replaces previous hook)\n        install_panic_hook();\n    }\n\n    // Note: Cannot easily test TerminalGuard::new() in CI since it requires a TTY.\n    // Manual testing required for:\n    // 1. Non-TTY detection: echo \"\" | cargo run -p atuin-ai -- inline\n    // 2. Drop cleanup: Run inline command, press Esc, verify terminal is normal\n    // 3. Panic recovery: Add panic!(\"test\") after TerminalGuard::new(), verify terminal is usable\n}\n"
  },
  {
    "path": "crates/atuin-ai/src/tui/view_model.rs",
    "content": "//! View model types for the TUI application\n//!\n//! This module contains the view model types that represent the rendering\n//! specification. These types are derived from the domain state (conversation\n//! events) via the `Blocks::from_state()` function.\n\nuse super::state::{AppMode, AppState, ConversationEvent};\n\n/// Warning classification for command suggestions\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\npub enum WarningKind {\n    /// Dangerous command (! indicator, AlertError color)\n    Danger,\n    /// Low confidence answer (? indicator, AlertWarn color)\n    LowConfidence,\n}\n\n/// Content variants for blocks - each variant is fully self-describing\n#[derive(Debug, Clone)]\npub enum Content {\n    Input {\n        text: String,\n        active: bool,\n        cursor_pos: usize,\n    },\n    /// Command suggestion (from suggest_command tool call)\n    Command {\n        text: String,\n        faded: bool, // Phase 5 feature\n    },\n    Text {\n        markdown: String,\n    },\n    Error {\n        message: String,\n    },\n    /// Warning for dangerous or low-confidence commands\n    Warning {\n        kind: WarningKind,\n        text: String,\n        pending_confirm: bool, // true when awaiting second Enter\n    },\n    Spinner {\n        frame: usize,        // 0-3 for animation\n        status_text: String, // Status-based text (Processing..., Thinking..., etc.)\n    },\n    /// Tool call status display (in-flight or completed summary)\n    ToolStatus {\n        /// Number of non-suggest_command tools completed\n        completed_count: usize,\n        /// Current in-flight tool description (None if all done)\n        current_label: Option<String>,\n        /// Spinner frame for in-flight display\n        frame: usize,\n    },\n}\n\nimpl Content {\n    /// Get the prefix symbol for this content type\n    pub fn prefix_symbol(&self) -> &'static str {\n        match self {\n            Content::Input { .. } => \">\",\n            Content::Command { .. } => \"$\",\n            Content::Text { .. } => \" \",\n            Content::Error { .. } => \"!\",\n            Content::Warning { kind, .. } => match kind {\n                WarningKind::Danger => \"!\",\n                WarningKind::LowConfidence => \"?\",\n            },\n            Content::Spinner { .. } => \"/\",\n            Content::ToolStatus { current_label, .. } => {\n                if current_label.is_some() {\n                    \"/\"\n                } else {\n                    \"\\u{2713}\"\n                } // spinner or checkmark\n            }\n        }\n    }\n}\n\n/// A visual block in the UI\n#[derive(Debug, Clone)]\npub struct Block {\n    pub content: Vec<Content>,\n    pub separator_above: bool,\n    pub title: Option<String>,\n}\n\n/// Status bar content shown on the bottom border during processing\n#[derive(Debug, Clone)]\npub struct StatusBar {\n    /// Spinner animation frame\n    pub frame: usize,\n    /// Status text to display (e.g., \"Thinking...\", \"run_bash (used 2 tools)\")\n    pub text: String,\n}\n\n/// Complete view model - the rendering specification\n#[derive(Debug, Clone)]\npub struct Blocks {\n    pub items: Vec<Block>,\n    pub footer: &'static str,\n    /// Transient status shown on bottom border during streaming/generating\n    pub status_bar: Option<StatusBar>,\n}\n\n/// Count non-suggest_command tool calls since the last user message\nfn count_tool_calls_since_last_user(events: &[ConversationEvent]) -> (usize, Option<String>) {\n    let last_user_idx = events\n        .iter()\n        .rposition(|e| matches!(e, ConversationEvent::UserMessage { .. }))\n        .unwrap_or(0);\n\n    let mut completed = 0;\n    let mut in_flight: Option<String> = None;\n\n    for event in &events[last_user_idx..] {\n        match event {\n            ConversationEvent::ToolCall { name, .. } if name != \"suggest_command\" => {\n                // New tool call starts as in-flight\n                if in_flight.is_some() {\n                    // Previous tool is now completed\n                    completed += 1;\n                }\n                in_flight = Some(name.clone());\n            }\n            ConversationEvent::ToolResult { .. } => {\n                // Tool completed\n                if in_flight.is_some() {\n                    completed += 1;\n                    in_flight = None;\n                }\n            }\n            _ => {}\n        }\n    }\n\n    (completed, in_flight)\n}\n\n/// Check if any turn in the conversation has a command\nfn has_any_command(events: &[ConversationEvent]) -> bool {\n    events.iter().any(|e| {\n        if let ConversationEvent::ToolCall { name, input, .. } = e {\n            name == \"suggest_command\" && input.get(\"command\").and_then(|v| v.as_str()).is_some()\n        } else {\n            false\n        }\n    })\n}\n\nimpl Blocks {\n    /// Pure function: derive the complete view model from state\n    ///\n    /// Iterates through conversation events and builds visual blocks.\n    /// Also handles streaming text and mode-dependent UI.\n    pub fn from_state(state: &AppState) -> Self {\n        let mut items = Vec::new();\n        let mut status_bar = None;\n\n        // 1. Build blocks from conversation events\n        for event in &state.events {\n            match event {\n                ConversationEvent::UserMessage { content } => {\n                    items.push(Block {\n                        content: vec![Content::Input {\n                            text: content.clone(),\n                            active: false,\n                            cursor_pos: 0,\n                        }],\n                        separator_above: false,\n                        title: None,\n                    });\n                }\n                ConversationEvent::Text { content } => {\n                    // In Review mode with completed tool calls, prepend ToolStatus to this Text block\n                    let (completed, _) = count_tool_calls_since_last_user(&state.events);\n                    let mut block_content = Vec::new();\n\n                    if state.mode == AppMode::Review && completed > 0 {\n                        block_content.push(Content::ToolStatus {\n                            completed_count: completed,\n                            current_label: None,\n                            frame: 0,\n                        });\n                    }\n\n                    block_content.push(Content::Text {\n                        markdown: content.clone(),\n                    });\n\n                    items.push(Block {\n                        content: block_content,\n                        separator_above: false,\n                        title: None,\n                    });\n                }\n                ConversationEvent::ToolCall { name, input, .. } => {\n                    // Only render suggest_command tool calls with a command\n                    if name == \"suggest_command\" {\n                        let command = input.get(\"command\").and_then(|v| v.as_str());\n\n                        // Build block content - only render if command is present\n                        // When command is null, this is a conversation-only turn and the\n                        // response text comes via a separate Text event\n                        let mut block_content = Vec::new();\n\n                        if let Some(cmd) = command {\n                            block_content.push(Content::Command {\n                                text: cmd.to_string(),\n                                faded: false,\n                            });\n                        }\n\n                        // Extract warning data from tool call input\n                        // danger: \"high\" | \"medium\" | \"med\" | \"low\" - high/medium/med trigger warning\n                        let danger_level = input\n                            .get(\"danger\")\n                            .and_then(|v| v.as_str())\n                            .unwrap_or(\"low\");\n                        let is_dangerous = danger_level == \"high\"\n                            || danger_level == \"medium\"\n                            || danger_level == \"med\";\n                        let danger_notes = input.get(\"danger_notes\").and_then(|v| v.as_str());\n\n                        // confidence: \"high\" | \"medium\" | \"low\" - low triggers warning\n                        let confidence_level = input\n                            .get(\"confidence\")\n                            .and_then(|v| v.as_str())\n                            .unwrap_or(\"high\");\n                        let is_low_confidence = confidence_level == \"low\";\n                        let confidence_notes =\n                            input.get(\"confidence_notes\").and_then(|v| v.as_str());\n\n                        // Add warning content if applicable (danger takes precedence)\n                        if is_dangerous {\n                            if let Some(notes) = danger_notes {\n                                block_content.push(Content::Warning {\n                                    kind: WarningKind::Danger,\n                                    text: notes.to_string(),\n                                    pending_confirm: state.confirmation_pending,\n                                });\n                            }\n                        } else if is_low_confidence && let Some(notes) = confidence_notes {\n                            block_content.push(Content::Warning {\n                                kind: WarningKind::LowConfidence,\n                                text: notes.to_string(),\n                                pending_confirm: false, // low confidence doesn't require confirm\n                            });\n                        }\n\n                        // Only add block if there's content\n                        if !block_content.is_empty() {\n                            items.push(Block {\n                                content: block_content,\n                                separator_above: false,\n                                title: None,\n                            });\n                        }\n                    }\n                    // Other tool calls are not rendered (internal protocol)\n                }\n                ConversationEvent::ToolResult { .. } => {\n                    // Tool results are not rendered (internal protocol)\n                }\n            }\n        }\n\n        // 2. AI response block (streaming text only) - shown during Streaming only\n        // Transient status (spinner, tool progress) goes to status_bar on the bottom border.\n        // In Review mode, ToolStatus is handled inline with ConversationEvent::Text above.\n        if state.mode == AppMode::Streaming {\n            let (completed, in_flight) = count_tool_calls_since_last_user(&state.events);\n\n            // Tool status -> status bar\n            if let Some(ref label) = in_flight {\n                let text = if completed > 0 {\n                    format!(\n                        \"{} (used {} tool{})\",\n                        label,\n                        completed,\n                        if completed == 1 { \"\" } else { \"s\" }\n                    )\n                } else {\n                    label.clone()\n                };\n                status_bar = Some(StatusBar {\n                    frame: state.spinner_frame,\n                    text,\n                });\n            }\n\n            // Spinner -> status bar (only when no text yet and no tool in-flight)\n            if state.streaming_text.is_empty() {\n                let should_show_spinner = state.streaming_status.is_some()\n                    || state\n                        .streaming_started\n                        .map(|start| start.elapsed() >= std::time::Duration::from_millis(200))\n                        .unwrap_or(true);\n\n                if should_show_spinner && in_flight.is_none() {\n                    let status_text = state\n                        .streaming_status\n                        .as_ref()\n                        .map(|s| s.display_text().to_string())\n                        .unwrap_or_else(|| \"Generating...\".to_string());\n\n                    status_bar = Some(StatusBar {\n                        frame: state.spinner_frame,\n                        text: status_text,\n                    });\n                }\n            } else {\n                // Show streaming text as content\n                items.push(Block {\n                    content: vec![Content::Text {\n                        markdown: state.streaming_text.clone(),\n                    }],\n                    separator_above: false,\n                    title: None,\n                });\n            }\n        }\n\n        // 3. Mode-dependent UI\n        match state.mode {\n            AppMode::Input => {\n                // Active input uses TextArea widget, rendered directly\n                // We add a placeholder block that will be replaced by textarea rendering\n                items.push(Block {\n                    content: vec![Content::Input {\n                        text: state.input(),\n                        active: true,\n                        cursor_pos: 0, // Not used for active input - textarea handles cursor\n                    }],\n                    separator_above: false,\n                    title: None,\n                });\n            }\n            AppMode::Generating => {\n                let status_text = state\n                    .streaming_status\n                    .as_ref()\n                    .map(|s| s.display_text().to_string())\n                    .unwrap_or_else(|| \"Generating...\".to_string());\n\n                status_bar = Some(StatusBar {\n                    frame: state.spinner_frame,\n                    text: status_text,\n                });\n            }\n            AppMode::Streaming => {\n                // Handled above in streaming text section\n            }\n            AppMode::Review | AppMode::Error => {\n                // No additional UI elements\n            }\n        }\n\n        // 4. Error if present (renders at end)\n        if let Some(ref err) = state.error {\n            items.push(Block {\n                content: vec![Content::Error {\n                    message: err.clone(),\n                }],\n                separator_above: false,\n                title: None,\n            });\n        }\n\n        // 5. Set separator flags (first has no separator)\n        for (idx, block) in items.iter_mut().enumerate() {\n            block.separator_above = idx > 0;\n        }\n\n        // 6. Set title on first block only\n        if let Some(first) = items.first_mut() {\n            first.title = Some(\"Ask questions or generate a command:\".to_string());\n        }\n\n        // 7. Derive footer from mode and events\n        let footer = Self::footer_for_mode(&state.mode, &state.events, state.confirmation_pending);\n\n        Self {\n            items,\n            footer,\n            status_bar,\n        }\n    }\n\n    /// Derive footer text from current mode and conversation state\n    fn footer_for_mode(\n        mode: &AppMode,\n        events: &[ConversationEvent],\n        confirmation_pending: bool,\n    ) -> &'static str {\n        match mode {\n            AppMode::Input => \"[Enter]: Accept  [Esc]: Cancel\",\n            AppMode::Generating | AppMode::Streaming => \"[Esc]: Cancel\",\n            AppMode::Review => {\n                if confirmation_pending {\n                    \"[Enter]: Confirm dangerous command  [Esc]: Cancel\"\n                } else if has_any_command(events) {\n                    \"[Enter]: Run  [Tab]: Insert  [f]: Follow-up  [Esc]: Cancel\"\n                } else {\n                    \"[f]: Follow-up  [Esc]: Cancel\"\n                }\n            }\n            AppMode::Error => \"[Enter]/[r]: Retry  [Esc]: Cancel\",\n        }\n    }\n}\n"
  },
  {
    "path": "crates/atuin-ai/test-renders.json",
    "content": "[\n  {\n    \"name\": \"01_empty_input\",\n    \"description\": \"Initial state with empty input prompt\",\n    \"state\": {\n      \"events\": [],\n      \"mode\": \"Input\",\n      \"input\": \"\",\n      \"cursor_pos\": 0\n    }\n  },\n  {\n    \"name\": \"02_typing_input\",\n    \"description\": \"User typing in input field\",\n    \"state\": {\n      \"events\": [],\n      \"mode\": \"Input\",\n      \"input\": \"list all files\",\n      \"cursor_pos\": 14\n    }\n  },\n  {\n    \"name\": \"03_generating_spinner\",\n    \"description\": \"Waiting for API response (spinner)\",\n    \"state\": {\n      \"events\": [\n        {\"type\": \"user_message\", \"content\": \"list all files\"}\n      ],\n      \"mode\": \"Generating\",\n      \"spinner_frame\": 0\n    }\n  },\n  {\n    \"name\": \"04_streaming_text\",\n    \"description\": \"Text streaming in from API\",\n    \"state\": {\n      \"events\": [\n        {\"type\": \"user_message\", \"content\": \"what is rust?\"}\n      ],\n      \"mode\": \"Streaming\",\n      \"streaming_text\": \"Rust is a systems programming language focused on safety, speed, and\",\n      \"spinner_frame\": 2\n    }\n  },\n  {\n    \"name\": \"05_simple_command\",\n    \"description\": \"Simple command suggestion\",\n    \"state\": {\n      \"events\": [\n        {\"type\": \"user_message\", \"content\": \"list all files\"},\n        {\"type\": \"tool_call\", \"id\": \"1\", \"name\": \"suggest_command\", \"input\": {\"command\": \"ls -la\"}}\n      ],\n      \"mode\": \"Review\"\n    }\n  },\n  {\n    \"name\": \"06_command_with_long_text\",\n    \"description\": \"Command that wraps to multiple lines\",\n    \"state\": {\n      \"events\": [\n        {\"type\": \"user_message\", \"content\": \"find large files\"},\n        {\"type\": \"tool_call\", \"id\": \"1\", \"name\": \"suggest_command\", \"input\": {\"command\": \"find /home -type f -size +100M -exec ls -lh {} \\\\; 2>/dev/null | sort -k5 -h\"}}\n      ],\n      \"mode\": \"Review\"\n    }\n  },\n  {\n    \"name\": \"07_conversation_only_response\",\n    \"description\": \"Response without command (conversation mode)\",\n    \"state\": {\n      \"events\": [\n        {\"type\": \"user_message\", \"content\": \"what does the -la flag do?\"},\n        {\"type\": \"text\", \"content\": \"The `-la` flags combine two options:\\n\\n- `-l` shows long format with permissions, owner, size, and date\\n- `-a` shows all files including hidden ones (starting with .)\"}\n      ],\n      \"mode\": \"Review\"\n    }\n  },\n  {\n    \"name\": \"08_multi_turn_conversation\",\n    \"description\": \"Multiple turns of conversation\",\n    \"state\": {\n      \"events\": [\n        {\"type\": \"user_message\", \"content\": \"list all files\"},\n        {\"type\": \"tool_call\", \"id\": \"1\", \"name\": \"suggest_command\", \"input\": {\"command\": \"ls -la\"}},\n        {\"type\": \"user_message\", \"content\": \"can you explain those flags?\"},\n        {\"type\": \"text\", \"content\": \"The -l flag shows long format with permissions, -a shows hidden files.\"}\n      ],\n      \"mode\": \"Review\"\n    }\n  },\n  {\n    \"name\": \"09_tool_call_in_progress\",\n    \"description\": \"Tool being executed (spinner)\",\n    \"state\": {\n      \"events\": [\n        {\"type\": \"user_message\", \"content\": \"what is the latest version of node?\"},\n        {\"type\": \"tool_call\", \"id\": \"1\", \"name\": \"web_search\", \"input\": {\"query\": \"nodejs latest version\"}}\n      ],\n      \"mode\": \"Streaming\",\n      \"streaming_text\": \"\",\n      \"spinner_frame\": 1\n    }\n  },\n  {\n    \"name\": \"10_tool_calls_completed_with_text\",\n    \"description\": \"Tools finished, text streaming\",\n    \"state\": {\n      \"events\": [\n        {\"type\": \"user_message\", \"content\": \"what is the latest version of node?\"},\n        {\"type\": \"tool_call\", \"id\": \"1\", \"name\": \"web_search\", \"input\": {\"query\": \"nodejs latest version\"}},\n        {\"type\": \"tool_result\", \"tool_use_id\": \"1\", \"content\": \"Node.js v22.0.0\"}\n      ],\n      \"mode\": \"Streaming\",\n      \"streaming_text\": \"The latest version of Node.js is v22.0.0, released in April 2024.\",\n      \"spinner_frame\": 0\n    }\n  },\n  {\n    \"name\": \"11_tool_calls_in_review\",\n    \"description\": \"Completed tools shown in review mode\",\n    \"state\": {\n      \"events\": [\n        {\"type\": \"user_message\", \"content\": \"what is the latest version of node?\"},\n        {\"type\": \"tool_call\", \"id\": \"1\", \"name\": \"web_search\", \"input\": {\"query\": \"nodejs latest version\"}},\n        {\"type\": \"tool_result\", \"tool_use_id\": \"1\", \"content\": \"Node.js v22.0.0\"},\n        {\"type\": \"tool_call\", \"id\": \"2\", \"name\": \"web_fetch\", \"input\": {\"url\": \"https://nodejs.org\"}},\n        {\"type\": \"tool_result\", \"tool_use_id\": \"2\", \"content\": \"...\"},\n        {\"type\": \"text\", \"content\": \"The latest version of Node.js is **v22.0.0**, released in April 2024. Key features include:\\n\\n- Native WebSocket client\\n- Improved ES modules support\\n- Better performance\"}\n      ],\n      \"mode\": \"Review\"\n    }\n  },\n  {\n    \"name\": \"12_error_state\",\n    \"description\": \"Error message displayed\",\n    \"state\": {\n      \"events\": [\n        {\"type\": \"user_message\", \"content\": \"do something\"}\n      ],\n      \"mode\": \"Error\",\n      \"error\": \"Failed to connect to API: connection timeout\"\n    }\n  },\n  {\n    \"name\": \"13_dangerous_command\",\n    \"description\": \"Dangerous command with warning\",\n    \"state\": {\n      \"events\": [\n        {\"type\": \"user_message\", \"content\": \"delete all files in home\"},\n        {\"type\": \"tool_call\", \"id\": \"1\", \"name\": \"suggest_command\", \"input\": {\n          \"command\": \"rm -rf ~/*\",\n          \"dangerous\": true,\n          \"warning\": \"This will permanently delete all files in your home directory including documents, configurations, and SSH keys.\"\n        }}\n      ],\n      \"mode\": \"Review\",\n      \"confirmation_pending\": false\n    }\n  },\n  {\n    \"name\": \"14_dangerous_command_confirming\",\n    \"description\": \"Dangerous command awaiting second Enter\",\n    \"state\": {\n      \"events\": [\n        {\"type\": \"user_message\", \"content\": \"delete all files in home\"},\n        {\"type\": \"tool_call\", \"id\": \"1\", \"name\": \"suggest_command\", \"input\": {\n          \"command\": \"rm -rf ~/*\",\n          \"dangerous\": true,\n          \"warning\": \"This will permanently delete all files in your home directory.\"\n        }}\n      ],\n      \"mode\": \"Review\",\n      \"confirmation_pending\": true\n    }\n  },\n  {\n    \"name\": \"15_low_confidence\",\n    \"description\": \"Low confidence command with warning\",\n    \"state\": {\n      \"events\": [\n        {\"type\": \"user_message\", \"content\": \"do that thing with the files\"},\n        {\"type\": \"tool_call\", \"id\": \"1\", \"name\": \"suggest_command\", \"input\": {\n          \"command\": \"ls -la\",\n          \"confidence\": \"low\",\n          \"warning\": \"I'm not entirely sure what you mean by 'that thing'. This lists files - is that what you wanted?\"\n        }}\n      ],\n      \"mode\": \"Review\"\n    }\n  },\n  {\n    \"name\": \"16_long_user_input\",\n    \"description\": \"User input that wraps\",\n    \"state\": {\n      \"events\": [\n        {\"type\": \"user_message\", \"content\": \"I need a command that will find all JavaScript files in my project, excluding node_modules, and count the total lines of code\"}\n      ],\n      \"mode\": \"Generating\",\n      \"spinner_frame\": 0\n    }\n  },\n  {\n    \"name\": \"17_long_text_response\",\n    \"description\": \"Long text response that wraps multiple times\",\n    \"state\": {\n      \"events\": [\n        {\"type\": \"user_message\", \"content\": \"explain git\"},\n        {\"type\": \"text\", \"content\": \"Git is a distributed version control system created by Linus Torvalds in 2005. It tracks changes to files and enables collaboration between developers. Key concepts include:\\n\\n- **Repository**: A directory containing your project and its history\\n- **Commit**: A snapshot of your changes with a message\\n- **Branch**: An independent line of development\\n- **Merge**: Combining changes from different branches\\n- **Remote**: A version of your repository hosted elsewhere (like GitHub)\"}\n      ],\n      \"mode\": \"Review\"\n    }\n  },\n  {\n    \"name\": \"18_streaming_with_tool_in_progress\",\n    \"description\": \"Tool in progress while streaming\",\n    \"state\": {\n      \"events\": [\n        {\"type\": \"user_message\", \"content\": \"search for rust async patterns\"},\n        {\"type\": \"text\", \"content\": \"Let me search for that...\"},\n        {\"type\": \"tool_call\", \"id\": \"1\", \"name\": \"web_search\", \"input\": {\"query\": \"rust async patterns\"}}\n      ],\n      \"mode\": \"Streaming\",\n      \"streaming_text\": \"\",\n      \"spinner_frame\": 2\n    }\n  },\n  {\n    \"name\": \"19_multiple_commands_in_conversation\",\n    \"description\": \"Multiple command suggestions across turns\",\n    \"state\": {\n      \"events\": [\n        {\"type\": \"user_message\", \"content\": \"create a new directory called test\"},\n        {\"type\": \"tool_call\", \"id\": \"1\", \"name\": \"suggest_command\", \"input\": {\"command\": \"mkdir test\"}},\n        {\"type\": \"user_message\", \"content\": \"now cd into it\"},\n        {\"type\": \"tool_call\", \"id\": \"2\", \"name\": \"suggest_command\", \"input\": {\"command\": \"cd test\"}},\n        {\"type\": \"user_message\", \"content\": \"create a file\"},\n        {\"type\": \"tool_call\", \"id\": \"3\", \"name\": \"suggest_command\", \"input\": {\"command\": \"touch file.txt\"}}\n      ],\n      \"mode\": \"Review\"\n    }\n  },\n  {\n    \"name\": \"20_empty_command_with_description\",\n    \"description\": \"Tool call with null command (conversation only)\",\n    \"state\": {\n      \"events\": [\n        {\"type\": \"user_message\", \"content\": \"what's the weather like?\"},\n        {\"type\": \"tool_call\", \"id\": \"1\", \"name\": \"suggest_command\", \"input\": {\n          \"command\": null,\n          \"description\": \"I can't check the weather directly, but you could use: curl wttr.in\"\n        }}\n      ],\n      \"mode\": \"Review\"\n    }\n  },\n  {\n    \"name\": \"21_status_processing\",\n    \"description\": \"Streaming with Processing status\",\n    \"state\": {\n      \"events\": [\n        {\"type\": \"user_message\", \"content\": \"analyze this code\"}\n      ],\n      \"mode\": \"Streaming\",\n      \"streaming_text\": \"\",\n      \"streaming_status\": \"Processing\",\n      \"spinner_frame\": 0\n    }\n  },\n  {\n    \"name\": \"22_status_thinking\",\n    \"description\": \"Streaming with Thinking status\",\n    \"state\": {\n      \"events\": [\n        {\"type\": \"user_message\", \"content\": \"how do I optimize this query?\"}\n      ],\n      \"mode\": \"Streaming\",\n      \"streaming_text\": \"\",\n      \"streaming_status\": \"Thinking\",\n      \"spinner_frame\": 1\n    }\n  },\n  {\n    \"name\": \"23_follow_up_input\",\n    \"description\": \"Follow-up input after command\",\n    \"state\": {\n      \"events\": [\n        {\"type\": \"user_message\", \"content\": \"list files\"},\n        {\"type\": \"tool_call\", \"id\": \"1\", \"name\": \"suggest_command\", \"input\": {\"command\": \"ls -la\"}}\n      ],\n      \"mode\": \"Input\",\n      \"input\": \"but only show directories\",\n      \"cursor_pos\": 24\n    }\n  }\n]\n"
  },
  {
    "path": "crates/atuin-client/Cargo.toml",
    "content": "[package]\nname = \"atuin-client\"\nedition = \"2024\"\ndescription = \"client library for atuin\"\n\nrust-version = { workspace = true }\nversion = { workspace = true }\nauthors = { workspace = true }\nlicense = { workspace = true }\nhomepage = { workspace = true }\nrepository = { workspace = true }\n\n# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html\n\n[features]\ndefault = [\"sync\", \"hub\", \"daemon\"]\nsync = [\"urlencoding\", \"reqwest\", \"sha2\", \"hex\"]\nhub = [\"reqwest\"]\ndaemon = []\ncheck-update = []\n\n[dependencies]\natuin-common = { path = \"../atuin-common\", version = \"18.13.3\" }\n\nlog = { workspace = true }\nbase64 = { workspace = true }\ntime = { workspace = true, features = [\"macros\", \"formatting\", \"parsing\"] }\nclap = { workspace = true }\neyre = { workspace = true }\ndirectories = { workspace = true }\nuuid = { workspace = true }\nwhoami = { workspace = true }\ninterim = { workspace = true }\nconfig = { workspace = true }\nserde = { workspace = true }\nserde_json = { workspace = true }\nhumantime = \"2.1.0\"\nasync-trait = { workspace = true }\nitertools = { workspace = true }\nrand = { workspace = true }\nshellexpand = \"3\"\nsqlx = { workspace = true, features = [\"sqlite\", \"regexp\"] }\nminspan = \"0.1.5\"\nregex = \"1.10.5\"\nserde_regex = \"1.1.0\"\nfs-err = { workspace = true }\nsql-builder = { workspace = true }\nmemchr = \"2.7\"\nrmp = { version = \"0.8.14\" }\ntyped-builder = { workspace = true }\ntokio = { workspace = true }\nsemver = { workspace = true }\nthiserror = { workspace = true }\nfutures = \"0.3\"\nnotify = \"7\"\ncrypto_secretbox = \"0.1.1\"\ngeneric-array = { version = \"0.14\", features = [\"serde\"] }\nserde_with = \"3.8.1\"\n\n# encryption\nrusty_paseto = { version = \"0.8.0\", default-features = false }\nrusty_paserk = { version = \"0.5.0\", default-features = false, features = [\n  \"v4\",\n  \"serde\",\n] }\n\n# sync\nurlencoding = { version = \"2.1.0\", optional = true }\nreqwest = { workspace = true, optional = true }\nhex = { version = \"0.4\", optional = true }\nsha2 = { version = \"0.10\", optional = true }\nindicatif = \"0.18.0\"\ntiny-bip39 = \"2.0.0\"\n\n# theme\ncrossterm = { workspace = true, features = [\"serde\"] }\npalette = { version = \"0.7.5\", features = [\"serializing\"] }\nstrum_macros = \"0.27\"\nstrum = { version = \"0.27\", features = [\"strum_macros\"] }\n\n[dev-dependencies]\ntokio = { version = \"1\", features = [\"full\"] }\npretty_assertions = { workspace = true }\ntesting_logger = \"0.1.1\"\n"
  },
  {
    "path": "crates/atuin-client/config.toml",
    "content": "## Base directory for Atuin data files (databases, keys, session, etc.)\n## All data file paths default to being relative to this directory.\n## linux/mac: ~/.local/share/atuin (or XDG_DATA_HOME/atuin)\n## windows: %USERPROFILE%/.local/share/atuin\n# data_dir = \"~/.local/share/atuin\"\n\n## where to store your database, default is your system data directory\n## linux/mac: ~/.local/share/atuin/history.db\n## windows: %USERPROFILE%/.local/share/atuin/history.db\n# db_path = \"~/.history.db\"\n\n## where to store your encryption key, default is your system data directory\n## linux/mac: ~/.local/share/atuin/key\n## windows: %USERPROFILE%/.local/share/atuin/key\n# key_path = \"~/.key\"\n\n## where to store your auth session token, default is your system data directory\n## linux/mac: ~/.local/share/atuin/session\n## windows: %USERPROFILE%/.local/share/atuin/session\n# session_path = \"~/.session\"\n\n## date format used, either \"us\" or \"uk\"\n# dialect = \"us\"\n\n## default timezone to use when displaying time\n## either \"l\", \"local\" to use the system's current local timezone, or an offset\n## from UTC in the format of \"<+|->H[H][:M[M][:S[S]]]\"\n## for example: \"+9\", \"-05\", \"+03:30\", \"-01:23:45\", etc.\n# timezone = \"local\"\n\n## enable or disable automatic sync\n# auto_sync = true\n\n## enable or disable automatic update checks\n# update_check = true\n\n## address of the sync server\n# sync_address = \"https://api.atuin.sh\"\n\n## how often to sync history. note that this is only triggered when a command\n## is ran, so sync intervals may well be longer\n## set it to 0 to sync after every command\n# sync_frequency = \"10m\"\n\n## which search mode to use\n## possible values: prefix, fulltext, fuzzy, skim\n# search_mode = \"fuzzy\"\n\n## which filter mode to use by default\n## possible values: \"global\", \"host\", \"session\", \"session-preload\", \"directory\", \"workspace\"\n## consider using search.filters to customize the enablement and order of filter modes\n# filter_mode = \"global\"\n\n## With workspace filtering enabled, Atuin will filter for commands executed\n## in any directory within a git repository tree (default: false).\n##\n## To use workspace mode by default when available, set this to true and\n## set filter_mode to \"workspace\" or leave it unspecified and\n## set search.filters to include \"workspace\" before other filter modes.\n# workspaces = false\n\n## which filter mode to use when atuin is invoked from a shell up-key binding\n## the accepted values are identical to those of \"filter_mode\"\n## leave unspecified to use same mode set in \"filter_mode\"\n# filter_mode_shell_up_key_binding = \"global\"\n\n## which search mode to use when atuin is invoked from a shell up-key binding\n## the accepted values are identical to those of \"search_mode\"\n## leave unspecified to use same mode set in \"search_mode\"\n# search_mode_shell_up_key_binding = \"fuzzy\"\n\n## which style to use\n## possible values: auto, full, compact\n# style = \"auto\"\n\n## the maximum number of lines the interface should take up\n## set it to 0 to always go full screen\n# inline_height = 0\n\n## the maximum number of lines the interface should take up\n## when atuin is invoked from a shell up-key binding\n## the accepted values are identical to those of \"inline_height\"\n# inline_height_shell_up_key_binding = 0\n\n## Invert the UI - put the search bar at the top , Default to `false`\n# invert = false\n\n## enable or disable showing a preview of the selected command\n## useful when the command is longer than the terminal width and is cut off\n# show_preview = true\n\n## what to do when the escape key is pressed when searching\n## possible values: return-original, return-query\n# exit_mode = \"return-original\"\n\n## possible values: emacs, subl\n# word_jump_mode = \"emacs\"\n\n## characters that count as a part of a word\n# word_chars = \"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789\"\n\n## number of context lines to show when scrolling by pages\n# scroll_context_lines = 1\n\n## use ctrl instead of alt as the shortcut modifier key for numerical UI shortcuts\n## alt-0 .. alt-9\n# ctrl_n_shortcuts = false\n\n## Show numeric shortcuts (1..9) beside list items in the TUI\n## set to false to hide the moving numbers if you find them distracting\n# show_numeric_shortcuts = true\n\n## default history list format - can also be specified with the --format arg\n# history_format = \"{time}\\t{command}\\t{duration}\"\n\n## prevent commands matching any of these regexes from being written to history.\n## Note that these regular expressions are unanchored, i.e. if they don't start\n## with ^ or end with $, they'll match anywhere in the command.\n## For details on the supported regular expression syntax, see\n## https://docs.rs/regex/latest/regex/#syntax\n# history_filter = [\n#   \"^secret-cmd\",\n#   \"^innocuous-cmd .*--secret=.+\",\n# ]\n\n## prevent commands run with cwd matching any of these regexes from being written\n## to history. Note that these regular expressions are unanchored, i.e. if they don't\n## start with ^ or end with $, they'll match anywhere in CWD.\n## For details on the supported regular expression syntax, see\n## https://docs.rs/regex/latest/regex/#syntax\n# cwd_filter = [\n#   \"^/very/secret/area\",\n# ]\n\n## Configure the maximum height of the preview to show.\n## Useful when you have long scripts in your history that you want to distinguish\n## by more than the first few lines.\n# max_preview_height = 4\n\n## Configure whether or not to show the help row, which includes the current Atuin\n## version (and whether an update is available), a keymap hint, and the total\n## amount of commands in your history.\n# show_help = true\n\n## Configure whether or not to show tabs for search and inspect\n# show_tabs = true\n\n## Configure whether or not the tabs row may be auto-hidden, which includes the current Atuin\n## tab, such as Search or Inspector, and other tabs you may wish to see. This will\n## only be hidden if there are fewer than this count of lines available, and does not affect the use\n## of keyboard shortcuts to switch tab. 0 to never auto-hide, default is 8 (lines).\n## This is ignored except in `compact` mode.\n# auto_hide_height = 8\n\n## Defaults to true. This matches history against a set of default regex, and will not save it if we get a match. Defaults include\n## 1. AWS key id\n## 2. Github pat (old and new)\n## 3. Slack oauth tokens (bot, user)\n## 4. Slack webhooks\n## 5. Stripe live/test keys\n# secrets_filter = true\n\n## Defaults to true. If enabled, upon hitting enter Atuin will immediately execute the command,\n## whereas tab will put the command in the prompt for editing.\n## If set to false, both enter and tab will place the command in the prompt for editing.\n## This applies for new installs. Old installs will keep the old behaviour unless configured otherwise.\nenter_accept = true\n\n## Defaults to false. If enabled, when triggered after &&, || or |, Atuin will complete commands to chain rather than replace the current line.\n# command_chaining = false\n\n## Defaults to \"emacs\".  This specifies the keymap on the startup of `atuin\n## search`.  If this is set to \"auto\", the startup keymap mode in the Atuin\n## search is automatically selected based on the shell's keymap where the\n## keybinding is defined.  If this is set to \"emacs\", \"vim-insert\", or\n## \"vim-normal\", the startup keymap mode in the Atuin search is forced to be\n## the specified one.\n# keymap_mode = \"auto\"\n\n## Cursor style in each keymap mode.  If specified, the cursor style is changed\n## in entering the cursor shape.  Available values are \"default\" and\n## \"{blink,steady}-{block,underline,bar}\".\n# keymap_cursor = { emacs = \"blink-block\", vim_insert = \"blink-block\", vim_normal = \"steady-block\" }\n\n# network_connect_timeout = 5\n# network_timeout = 5\n\n## Timeout (in seconds) for acquiring a local database connection (sqlite)\n# local_timeout = 5\n\n## Set this to true and Atuin will minimize motion in the UI - timers will not update live, etc.\n## Alternatively, set env NO_MOTION=true\n# prefers_reduced_motion = false\n\n[stats]\n## Set commands where we should consider the subcommand for statistics. Eg, kubectl get vs just kubectl\n# common_subcommands = [\n#   \"apt\",\n#   \"cargo\",\n#   \"composer\",\n#   \"dnf\",\n#   \"docker\",\n#   \"dotnet\",\n#   \"git\",\n#   \"go\",\n#   \"ip\",\n#   \"jj\",\n#   \"kubectl\",\n#   \"nix\",\n#   \"nmcli\",\n#   \"npm\",\n#   \"pecl\",\n#   \"pnpm\",\n#   \"podman\",\n#   \"port\",\n#   \"systemctl\",\n#   \"tmux\",\n#   \"yarn\",\n# ]\n\n## Set commands that should be totally stripped and ignored from stats\n# common_prefix = [\"sudo\"]\n\n## Set commands that will be completely ignored from stats\n# ignored_commands = [\n#   \"cd\",\n#   \"ls\",\n#   \"vi\"\n# ]\n\n[keys]\n# Defaults to true. If disabled, using the up/down key won't exit the TUI when scrolled past the first/last entry.\n# scroll_exits = true\n\n# Defaults to true. The left arrow key will exit the TUI when scrolling before the first character\n# exit_past_line_start = true\n\n# Defaults to true. The right arrow key performs the same functionality as Tab and copies the selected line to the command line to be modified.\n# accept_past_line_end = true\n\n# Defaults to false. The left arrow key performs the same functionality as Tab and copies the selected line to the command line to be modified.\n# accept_past_line_start = false\n\n# Defaults to false. The backspace key performs the same functionality as Tab and copies the selected line to the command line to be modified when at the start of the line.\n# accept_with_backspace = false\n\n[sync]\n# Enable sync v2 by default\n# This ensures that sync v2 is enabled for new installs only\n# In a later release it will become the default across the board\nrecords = true\n\n[preview]\n## which preview strategy to use to calculate the preview height (respects max_preview_height).\n## possible values: auto, static\n## auto: length of the selected command.\n## static: length of the longest command stored in the history.\n## fixed: use max_preview_height as fixed height.\n# strategy = \"auto\"\n\n[daemon]\n## Enables using the daemon to sync.\n# enabled = false\n\n## Automatically start and manage the daemon when needed.\n## Not compatible with `systemd_socket = true`.\n# autostart = false\n\n## How often the daemon should sync in seconds\n# sync_frequency = 300\n\n## The path to the unix socket used by the daemon (on unix systems)\n## linux/mac: ~/.local/share/atuin/atuin.sock\n## windows: Not Supported\n# socket_path = \"~/.local/share/atuin/atuin.sock\"\n\n## The daemon pidfile used for lifecycle management.\n## Defaults to the Atuin data directory.\n# pidfile_path = \"~/.local/share/atuin/atuin-daemon.pid\"\n\n## Use systemd socket activation rather than opening the given path (the path must still be correct for the client)\n## linux: false\n## mac/windows: Not Supported\n# systemd_socket = false\n\n## The port that should be used for TCP on non unix systems\n# tcp_port = 8889\n\n# [theme]\n## Color theme to use for rendering in the terminal.\n## There are some built-in themes, including the base theme (\"default\"),\n## \"autumn\" and \"marine\". You can add your own themes to the \"./themes\" subdirectory of your\n## Atuin config (or ATUIN_THEME_DIR, if provided) as TOML files whose keys should be one or\n## more of AlertInfo, AlertWarn, AlertError, Annotation, Base, Guidance, Important, and\n## the string values as lowercase entries from this list:\n##    https://ogeon.github.io/docs/palette/master/palette/named/index.html\n## If you provide a custom theme file, it should be  called \"NAME.toml\" and the theme below\n## should be the stem, i.e. `theme = \"NAME\"` for your chosen NAME.\n# name = \"autumn\"\n\n## Whether the theme manager should output normal or extra information to help fix themes.\n## Boolean, true or false. If unset, left up to the theme manager.\n# debug = true\n\n[search]\n## The list of enabled filter modes, in order of priority.\n## The \"workspace\" mode is skipped when not in a workspace or workspaces = false.\n## Default filter mode can be overridden with the filter_mode setting.\n# filters = [ \"global\", \"host\", \"session\", \"session-preload\", \"workspace\", \"directory\" ]\n\n[tmux]\n## Enable using atuin with tmux popup (requires tmux >= 3.2)\n## When enabled and running inside tmux, Atuin will use a popup window for interactive search.\n## Set to false to disable the popup.\n## This can also be controlled with the ATUIN_TMUX_POPUP environment variable.\n## Note: The tmux popup is currently supported in zsh, bash, and fish shells. This currently doesn't work with iTerm native tmux integration.\n# enabled = false\n\n## Width of the tmux popup window\n## Can be a percentage, or integer (e.g. \"100\" means 100 characters wide)\n# width = \"80%\"\n\n## Height of the tmux popup window\n## Can be a percentage, or integer (e.g. \"100\" means 100 lines tall)\n# height = \"60%\"\n\n[ui]\n## Columns to display in the interactive search, from left to right.\n## The selection indicator (\" > \") is always shown first implicitly.\n##\n## Each column can be specified as a simple string (uses default width)\n## or as an object with type, width, and expand:\n##   { type = \"directory\", width = 30, expand = true }\n##\n## Available column types (with default widths):\n##   duration  (5)  - Command execution duration (e.g., \"123ms\")\n##   time      (8)  - Relative time since execution (e.g., \"59m ago\")\n##   datetime  (16) - Absolute timestamp (e.g., \"2025-01-22 14:35\")\n##   directory (20) - Working directory (truncated if too long)\n##   host      (15) - Hostname where command was run\n##   user      (10) - Username\n##   exit      (3)  - Exit code (colored by success/failure)\n##   command   (*)  - The command itself (expands by default)\n##\n## The \"expand\" option (default: true for command, false for others) makes a\n## column fill remaining space. Only one column should have expand = true.\n##\n## Default:\n# columns = [\"duration\", \"time\", \"command\"]\n##\n## Examples:\n##\n## Minimal - more space for commands:\n# columns = [\"duration\", \"command\"]\n##\n## With wider directory column:\n# columns = [\"duration\", { type = \"directory\", width = 30 }, \"command\"]\n##\n## Show host for multi-machine sync users:\n# columns = [\"duration\", \"time\", \"host\", \"command\"]\n##\n## Show exit codes prominently:\n# columns = [\"exit\", \"duration\", \"command\"]\n##\n## Make directory expand instead of command:\n# columns = [\"duration\", \"time\", { type = \"directory\", expand = true }, { type = \"command\", expand = false }]\n"
  },
  {
    "path": "crates/atuin-client/meta-migrations/20260203030924_create_meta.sql",
    "content": "create table if not exists meta (\n    key text not null primary key,\n    value text not null,\n    updated_at integer not null default (strftime('%s', 'now'))\n);\n"
  },
  {
    "path": "crates/atuin-client/migrations/20210422143411_create_history.sql",
    "content": "-- Add migration script here\ncreate table if not exists history (\n\tid text primary key,\n\ttimestamp integer not null,\n\tduration integer not null,\n\texit integer not null,\n\tcommand text not null,\n\tcwd text not null,\n\tsession text not null,\n\thostname text not null,\n\n\tunique(timestamp, cwd, command)\n);\n\ncreate index if not exists idx_history_timestamp on history(timestamp);\ncreate index if not exists idx_history_command on history(command);\n"
  },
  {
    "path": "crates/atuin-client/migrations/20220505083406_create-events.sql",
    "content": "create table if not exists events (\n\tid text primary key,\n\ttimestamp integer not null,\n\thostname text not null,\n\tevent_type text not null,\n\n\thistory_id text not null\n);\n\n-- Ensure there is only ever one of each event type per history item\ncreate unique index history_event_idx ON events(event_type, history_id);\n"
  },
  {
    "path": "crates/atuin-client/migrations/20220806155627_interactive_search_index.sql",
    "content": "-- Interactive search filters by command then by the max(timestamp) for that\n-- command. Create an index that covers those\ncreate index if not exists idx_history_command_timestamp on history(\n\tcommand,\n\ttimestamp\n);\n"
  },
  {
    "path": "crates/atuin-client/migrations/20230315220114_drop-events.sql",
    "content": "-- Add migration script here\ndrop table events;\n"
  },
  {
    "path": "crates/atuin-client/migrations/20230319185725_deleted_at.sql",
    "content": "-- Add migration script here\nalter table history add column deleted_at integer;\n"
  },
  {
    "path": "crates/atuin-client/migrations/20260224000100_history_author_intent.sql",
    "content": "alter table history add column author text;\nalter table history add column intent text;\n"
  },
  {
    "path": "crates/atuin-client/record-migrations/20230531212437_create-records.sql",
    "content": "-- Add migration script here\ncreate table if not exists records (\n  id text primary key,\n  parent text unique, -- null if this is the first one\n  host text not null,\n\n  timestamp integer not null,\n  tag text not null,\n  version text not null,\n  data blob not null,\n  cek blob not null\n);\n\ncreate index host_idx on records (host);\ncreate index tag_idx on records (tag);\ncreate index host_tag_idx on records (host, tag);\n"
  },
  {
    "path": "crates/atuin-client/record-migrations/20231127090831_create-store.sql",
    "content": "-- Add migration script here\ncreate table if not exists store (\n  id text primary key,   -- globally unique ID\n\n  idx integer,           -- incrementing integer ID unique per (host, tag)\n  host text not null, -- references the host row\n  tag text not null,\n\n  timestamp integer not null,\n  version text not null,\n  data blob not null,\n  cek blob not null\n);\n\ncreate unique index record_uniq ON store(host, tag, idx);\n"
  },
  {
    "path": "crates/atuin-client/src/api_client.rs",
    "content": "use std::collections::HashMap;\nuse std::env;\nuse std::time::Duration;\n\nuse eyre::{Result, bail, eyre};\nuse reqwest::{\n    Response, StatusCode, Url,\n    header::{AUTHORIZATION, HeaderMap, USER_AGENT},\n};\n\nuse atuin_common::{\n    api::{ATUIN_CARGO_VERSION, ATUIN_HEADER_VERSION, ATUIN_VERSION},\n    record::{EncryptedData, HostId, Record, RecordIdx},\n    tls::ensure_crypto_provider,\n};\nuse atuin_common::{\n    api::{\n        AddHistoryRequest, ChangePasswordRequest, CountResponse, DeleteHistoryRequest,\n        ErrorResponse, LoginRequest, LoginResponse, MeResponse, RegisterResponse, StatusResponse,\n        SyncHistoryResponse,\n    },\n    record::RecordStatus,\n};\n\nuse semver::Version;\nuse time::OffsetDateTime;\nuse time::format_description::well_known::Rfc3339;\n\nuse crate::{history::History, sync::hash_str, utils::get_host_user};\n\nstatic APP_USER_AGENT: &str = concat!(\"atuin/\", env!(\"CARGO_PKG_VERSION\"),);\n\n/// Authentication token for sync API requests.\n///\n/// The sync API supports two authentication methods:\n/// - `Bearer`: Hub API tokens (for users authenticated via Atuin Hub)\n/// - `Token`: Legacy CLI session tokens (for users registered via CLI or self-hosted)\n///\n/// When both are available, Hub tokens are preferred as they provide unified\n/// authentication across CLI and Hub features.\n#[derive(Debug, Clone)]\npub enum AuthToken {\n    /// Hub API token, used with \"Bearer {token}\" header\n    Bearer(String),\n    /// Legacy CLI session token, used with \"Token {token}\" header\n    Token(String),\n}\n\nimpl AuthToken {\n    /// Format the token as an Authorization header value\n    fn to_header_value(&self) -> String {\n        match self {\n            AuthToken::Bearer(token) => format!(\"Bearer {token}\"),\n            AuthToken::Token(token) => format!(\"Token {token}\"),\n        }\n    }\n}\n\npub struct Client<'a> {\n    sync_addr: &'a str,\n    client: reqwest::Client,\n}\n\nfn make_url(address: &str, path: &str) -> Result<String> {\n    // `join()` expects a trailing `/` in order to join paths\n    // e.g. it treats `http://host:port/subdir` as a file called `subdir`\n    let address = if address.ends_with(\"/\") {\n        address\n    } else {\n        &format!(\"{address}/\")\n    };\n\n    // passing a path with a leading `/` will cause `join()` to replace the entire URL path\n    let path = path.strip_prefix(\"/\").unwrap_or(path);\n\n    let url = Url::parse(address)\n        .map(|url| url.join(path))?\n        .map_err(|_| eyre!(\"invalid address\"))?;\n\n    Ok(url.to_string())\n}\n\npub async fn register(\n    address: &str,\n    username: &str,\n    email: &str,\n    password: &str,\n) -> Result<RegisterResponse> {\n    ensure_crypto_provider();\n    let mut map = HashMap::new();\n    map.insert(\"username\", username);\n    map.insert(\"email\", email);\n    map.insert(\"password\", password);\n\n    let url = make_url(address, &format!(\"/user/{username}\"))?;\n    let resp = reqwest::get(url).await?;\n\n    if resp.status().is_success() {\n        bail!(\"username already in use\");\n    }\n\n    let url = make_url(address, \"/register\")?;\n    let client = reqwest::Client::new();\n    let resp = client\n        .post(url)\n        .header(USER_AGENT, APP_USER_AGENT)\n        .header(ATUIN_HEADER_VERSION, ATUIN_CARGO_VERSION)\n        .json(&map)\n        .send()\n        .await?;\n    let resp = handle_resp_error(resp).await?;\n\n    if !ensure_version(&resp)? {\n        bail!(\"could not register user due to version mismatch\");\n    }\n\n    let session = resp.json::<RegisterResponse>().await?;\n    Ok(session)\n}\n\npub async fn login(address: &str, req: LoginRequest) -> Result<LoginResponse> {\n    ensure_crypto_provider();\n    let url = make_url(address, \"/login\")?;\n    let client = reqwest::Client::new();\n\n    let resp = client\n        .post(url)\n        .header(USER_AGENT, APP_USER_AGENT)\n        .json(&req)\n        .send()\n        .await?;\n    let resp = handle_resp_error(resp).await?;\n\n    if !ensure_version(&resp)? {\n        bail!(\"Could not login due to version mismatch\");\n    }\n\n    let session = resp.json::<LoginResponse>().await?;\n    Ok(session)\n}\n\n#[cfg(feature = \"check-update\")]\npub async fn latest_version() -> Result<Version> {\n    use atuin_common::api::IndexResponse;\n\n    ensure_crypto_provider();\n    let url = \"https://api.atuin.sh\";\n    let client = reqwest::Client::new();\n\n    let resp = client\n        .get(url)\n        .header(USER_AGENT, APP_USER_AGENT)\n        .send()\n        .await?;\n    let resp = handle_resp_error(resp).await?;\n\n    let index = resp.json::<IndexResponse>().await?;\n    let version = Version::parse(index.version.as_str())?;\n\n    Ok(version)\n}\n\npub fn ensure_version(response: &Response) -> Result<bool> {\n    let version = response.headers().get(ATUIN_HEADER_VERSION);\n\n    let version = if let Some(version) = version {\n        match version.to_str() {\n            Ok(v) => Version::parse(v),\n            Err(e) => bail!(\"failed to parse server version: {:?}\", e),\n        }\n    } else {\n        bail!(\"Server not reporting its version: it is either too old or unhealthy\");\n    }?;\n\n    // If the client is newer than the server\n    if version.major < ATUIN_VERSION.major {\n        println!(\n            \"Atuin version mismatch! In order to successfully sync, the server needs to run a newer version of Atuin\"\n        );\n        println!(\"Client: {ATUIN_CARGO_VERSION}\");\n        println!(\"Server: {version}\");\n\n        return Ok(false);\n    }\n\n    Ok(true)\n}\n\nasync fn handle_resp_error(resp: Response) -> Result<Response> {\n    let status = resp.status();\n    let url = resp.url().to_string();\n\n    if status == StatusCode::SERVICE_UNAVAILABLE {\n        bail!(\n            \"Service unavailable: check https://status.atuin.sh (or get in touch with your host)\"\n        );\n    }\n\n    if status == StatusCode::TOO_MANY_REQUESTS {\n        bail!(\"Rate limited; please wait before doing that again\");\n    }\n\n    if !status.is_success() {\n        if let Ok(error) = resp.json::<ErrorResponse>().await {\n            let reason = error.reason;\n\n            if status.is_client_error() {\n                bail!(\"Invalid request to the service at {url}, {status} - {reason}.\")\n            }\n\n            bail!(\n                \"There was an error with the atuin sync service at {url}, server error {status}: {reason}.\\nIf the problem persists, contact the host\"\n            )\n        }\n\n        bail!(\n            \"There was an error with the atuin sync service at {url}, Status {status:?}.\\nIf the problem persists, contact the host\"\n        )\n    }\n\n    Ok(resp)\n}\n\nimpl<'a> Client<'a> {\n    pub fn new(\n        sync_addr: &'a str,\n        auth: AuthToken,\n        connect_timeout: u64,\n        timeout: u64,\n    ) -> Result<Self> {\n        ensure_crypto_provider();\n        let mut headers = HeaderMap::new();\n        headers.insert(AUTHORIZATION, auth.to_header_value().parse()?);\n\n        // used for semver server check\n        headers.insert(ATUIN_HEADER_VERSION, ATUIN_CARGO_VERSION.parse()?);\n\n        Ok(Client {\n            sync_addr,\n            client: reqwest::Client::builder()\n                .user_agent(APP_USER_AGENT)\n                .default_headers(headers)\n                .connect_timeout(Duration::new(connect_timeout, 0))\n                .timeout(Duration::new(timeout, 0))\n                .build()?,\n        })\n    }\n\n    pub async fn count(&self) -> Result<i64> {\n        let url = make_url(self.sync_addr, \"/sync/count\")?;\n        let url = Url::parse(url.as_str())?;\n\n        let resp = self.client.get(url).send().await?;\n        let resp = handle_resp_error(resp).await?;\n\n        if !ensure_version(&resp)? {\n            bail!(\"could not sync due to version mismatch\");\n        }\n\n        if resp.status() != StatusCode::OK {\n            bail!(\"failed to get count (are you logged in?)\");\n        }\n\n        let count = resp.json::<CountResponse>().await?;\n\n        Ok(count.count)\n    }\n\n    pub async fn status(&self) -> Result<StatusResponse> {\n        let url = make_url(self.sync_addr, \"/sync/status\")?;\n        let url = Url::parse(url.as_str())?;\n\n        let resp = self.client.get(url).send().await?;\n        let resp = handle_resp_error(resp).await?;\n\n        if !ensure_version(&resp)? {\n            bail!(\"could not sync due to version mismatch\");\n        }\n\n        let status = resp.json::<StatusResponse>().await?;\n\n        Ok(status)\n    }\n\n    pub async fn me(&self) -> Result<MeResponse> {\n        let url = make_url(self.sync_addr, \"/api/v0/me\")?;\n        let url = Url::parse(url.as_str())?;\n\n        let resp = self.client.get(url).send().await?;\n        let resp = handle_resp_error(resp).await?;\n\n        let status = resp.json::<MeResponse>().await?;\n\n        Ok(status)\n    }\n\n    pub async fn get_history(\n        &self,\n        sync_ts: OffsetDateTime,\n        history_ts: OffsetDateTime,\n        host: Option<String>,\n    ) -> Result<SyncHistoryResponse> {\n        let host = host.unwrap_or_else(|| hash_str(&get_host_user()));\n\n        let url = make_url(\n            self.sync_addr,\n            &format!(\n                \"/sync/history?sync_ts={}&history_ts={}&host={}\",\n                urlencoding::encode(sync_ts.format(&Rfc3339)?.as_str()),\n                urlencoding::encode(history_ts.format(&Rfc3339)?.as_str()),\n                host,\n            ),\n        )?;\n\n        let resp = self.client.get(url).send().await?;\n        let resp = handle_resp_error(resp).await?;\n\n        let history = resp.json::<SyncHistoryResponse>().await?;\n        Ok(history)\n    }\n\n    pub async fn post_history(&self, history: &[AddHistoryRequest]) -> Result<()> {\n        let url = make_url(self.sync_addr, \"/history\")?;\n        let url = Url::parse(url.as_str())?;\n\n        let resp = self.client.post(url).json(history).send().await?;\n        handle_resp_error(resp).await?;\n\n        Ok(())\n    }\n\n    pub async fn delete_history(&self, h: History) -> Result<()> {\n        let url = make_url(self.sync_addr, \"/history\")?;\n        let url = Url::parse(url.as_str())?;\n\n        let resp = self\n            .client\n            .delete(url)\n            .json(&DeleteHistoryRequest {\n                client_id: h.id.to_string(),\n            })\n            .send()\n            .await?;\n\n        handle_resp_error(resp).await?;\n\n        Ok(())\n    }\n\n    pub async fn delete_store(&self) -> Result<()> {\n        let url = make_url(self.sync_addr, \"/api/v0/store\")?;\n        let url = Url::parse(url.as_str())?;\n\n        let resp = self.client.delete(url).send().await?;\n\n        handle_resp_error(resp).await?;\n\n        Ok(())\n    }\n\n    pub async fn post_records(&self, records: &[Record<EncryptedData>]) -> Result<()> {\n        let url = make_url(self.sync_addr, \"/api/v0/record\")?;\n        let url = Url::parse(url.as_str())?;\n\n        debug!(\"uploading {} records to {url}\", records.len());\n\n        let resp = self.client.post(url).json(records).send().await?;\n        handle_resp_error(resp).await?;\n\n        Ok(())\n    }\n\n    pub async fn next_records(\n        &self,\n        host: HostId,\n        tag: String,\n        start: RecordIdx,\n        count: u64,\n    ) -> Result<Vec<Record<EncryptedData>>> {\n        debug!(\"fetching record/s from host {}/{}/{}\", host.0, tag, start);\n\n        let url = make_url(\n            self.sync_addr,\n            &format!(\n                \"/api/v0/record/next?host={}&tag={}&count={}&start={}\",\n                host.0, tag, count, start\n            ),\n        )?;\n\n        let url = Url::parse(url.as_str())?;\n\n        let resp = self.client.get(url).send().await?;\n        let resp = handle_resp_error(resp).await?;\n\n        let records = resp.json::<Vec<Record<EncryptedData>>>().await?;\n\n        Ok(records)\n    }\n\n    pub async fn record_status(&self) -> Result<RecordStatus> {\n        let url = make_url(self.sync_addr, \"/api/v0/record\")?;\n        let url = Url::parse(url.as_str())?;\n\n        let resp = self.client.get(url).send().await?;\n        let resp = handle_resp_error(resp).await?;\n\n        if !ensure_version(&resp)? {\n            bail!(\"could not sync records due to version mismatch\");\n        }\n\n        let index = resp.json().await?;\n\n        debug!(\"got remote index {index:?}\");\n\n        Ok(index)\n    }\n\n    pub async fn delete(&self) -> Result<()> {\n        let url = make_url(self.sync_addr, \"/account\")?;\n        let url = Url::parse(url.as_str())?;\n\n        let resp = self.client.delete(url).send().await?;\n\n        if resp.status() == 403 {\n            bail!(\"invalid login details\");\n        } else if resp.status() == 200 {\n            Ok(())\n        } else {\n            bail!(\"Unknown error\");\n        }\n    }\n\n    pub async fn change_password(\n        &self,\n        current_password: String,\n        new_password: String,\n    ) -> Result<()> {\n        let url = make_url(self.sync_addr, \"/account/password\")?;\n        let url = Url::parse(url.as_str())?;\n\n        let resp = self\n            .client\n            .patch(url)\n            .json(&ChangePasswordRequest {\n                current_password,\n                new_password,\n            })\n            .send()\n            .await?;\n\n        if resp.status() == 401 {\n            bail!(\"current password is incorrect\")\n        } else if resp.status() == 403 {\n            bail!(\"invalid login details\");\n        } else if resp.status() == 200 {\n            Ok(())\n        } else {\n            bail!(\"Unknown error\");\n        }\n    }\n}\n"
  },
  {
    "path": "crates/atuin-client/src/auth.rs",
    "content": "use async_trait::async_trait;\nuse eyre::{Context, Result, bail};\nuse reqwest::{StatusCode, Url, header::USER_AGENT};\nuse serde::Deserialize;\n\nuse atuin_common::{\n    api::{\n        ATUIN_CARGO_VERSION, ATUIN_HEADER_VERSION, ChangePasswordRequest, LoginRequest,\n        LoginResponse, RegisterResponse,\n    },\n    tls::ensure_crypto_provider,\n};\n\nuse crate::settings::Settings;\n\nstatic APP_USER_AGENT: &str = concat!(\"atuin/\", env!(\"CARGO_PKG_VERSION\"));\n\n/// Result of an auth operation that may require 2FA.\npub enum AuthResponse {\n    /// Operation succeeded; for login/register, contains the session token.\n    Success { session: String },\n    /// Two-factor authentication is required; the caller should prompt for a\n    /// TOTP code and retry with it.\n    TwoFactorRequired,\n}\n\n/// Result of a mutating account operation that may require 2FA.\npub enum MutateResponse {\n    /// Operation completed successfully.\n    Success,\n    /// Two-factor authentication is required; the caller should prompt for a\n    /// TOTP code and retry.\n    TwoFactorRequired,\n}\n\n/// Abstraction over the legacy (Rust sync server) and Hub auth APIs.\n///\n/// CLI commands use this trait so they don't need to know which backend is\n/// active — they just prompt for input and call these methods.\n#[async_trait]\npub trait AuthClient: Send + Sync {\n    /// Log in with username + password, optionally providing a TOTP code.\n    async fn login(\n        &self,\n        username: &str,\n        password: &str,\n        totp_code: Option<&str>,\n    ) -> Result<AuthResponse>;\n\n    /// Register a new account.\n    async fn register(&self, username: &str, email: &str, password: &str) -> Result<AuthResponse>;\n\n    /// Change the account password, optionally providing a TOTP code.\n    async fn change_password(\n        &self,\n        current_password: &str,\n        new_password: &str,\n        totp_code: Option<&str>,\n    ) -> Result<MutateResponse>;\n\n    /// Delete the account, requiring the current password and optionally a TOTP code.\n    async fn delete_account(\n        &self,\n        password: &str,\n        totp_code: Option<&str>,\n    ) -> Result<MutateResponse>;\n}\n\n/// Resolve the appropriate [`AuthClient`] for the current settings.\npub async fn auth_client(settings: &Settings) -> Box<dyn AuthClient> {\n    if settings.is_hub_sync() {\n        let endpoint = settings.active_hub_endpoint().unwrap_or_default();\n        Box::new(HubAuthClient::new(\n            endpoint.as_ref(),\n            settings.hub_session_token().await.ok(),\n        )) as Box<dyn AuthClient>\n    } else {\n        Box::new(LegacyAuthClient::new(\n            &settings.sync_address,\n            settings.session_token().await.ok(),\n            settings.network_connect_timeout,\n            settings.network_timeout,\n        )) as Box<dyn AuthClient>\n    }\n}\n\n// ---------------------------------------------------------------------------\n// Legacy backend — talks to the Rust sync server\n// ---------------------------------------------------------------------------\n\npub struct LegacyAuthClient {\n    address: String,\n    session_token: Option<String>,\n    connect_timeout: u64,\n    timeout: u64,\n}\n\nimpl LegacyAuthClient {\n    pub fn new(\n        address: &str,\n        session_token: Option<String>,\n        connect_timeout: u64,\n        timeout: u64,\n    ) -> Self {\n        Self {\n            address: address.to_string(),\n            session_token,\n            connect_timeout,\n            timeout,\n        }\n    }\n\n    fn authenticated_client(&self) -> Result<reqwest::Client> {\n        let token = self\n            .session_token\n            .as_deref()\n            .ok_or_else(|| eyre::eyre!(\"Not logged in\"))?;\n\n        ensure_crypto_provider();\n        let mut headers = reqwest::header::HeaderMap::new();\n        headers.insert(\n            reqwest::header::AUTHORIZATION,\n            format!(\"Token {token}\").parse()?,\n        );\n        headers.insert(USER_AGENT, APP_USER_AGENT.parse()?);\n        headers.insert(ATUIN_HEADER_VERSION, ATUIN_CARGO_VERSION.parse()?);\n\n        Ok(reqwest::Client::builder()\n            .default_headers(headers)\n            .connect_timeout(std::time::Duration::new(self.connect_timeout, 0))\n            .timeout(std::time::Duration::new(self.timeout, 0))\n            .build()?)\n    }\n}\n\n#[async_trait]\nimpl AuthClient for LegacyAuthClient {\n    async fn login(\n        &self,\n        username: &str,\n        password: &str,\n        _totp_code: Option<&str>,\n    ) -> Result<AuthResponse> {\n        // The legacy server has no 2FA support; totp_code is ignored.\n        let resp = crate::api_client::login(\n            &self.address,\n            LoginRequest {\n                username: username.to_string(),\n                password: password.to_string(),\n            },\n        )\n        .await?;\n\n        Ok(AuthResponse::Success {\n            session: resp.session,\n        })\n    }\n\n    async fn register(&self, username: &str, email: &str, password: &str) -> Result<AuthResponse> {\n        let resp = crate::api_client::register(&self.address, username, email, password).await?;\n        Ok(AuthResponse::Success {\n            session: resp.session,\n        })\n    }\n\n    async fn change_password(\n        &self,\n        current_password: &str,\n        new_password: &str,\n        _totp_code: Option<&str>,\n    ) -> Result<MutateResponse> {\n        let client = self.authenticated_client()?;\n        let url = make_url(&self.address, \"/account/password\")?;\n\n        let resp = client\n            .patch(&url)\n            .json(&ChangePasswordRequest {\n                current_password: current_password.to_string(),\n                new_password: new_password.to_string(),\n            })\n            .send()\n            .await?;\n\n        match resp.status().as_u16() {\n            200 => Ok(MutateResponse::Success),\n            401 => bail!(\"current password is incorrect\"),\n            403 => bail!(\"invalid login details\"),\n            _ => bail!(\"unknown error\"),\n        }\n    }\n\n    async fn delete_account(\n        &self,\n        password: &str,\n        _totp_code: Option<&str>,\n    ) -> Result<MutateResponse> {\n        let client = self.authenticated_client()?;\n        let url = make_url(&self.address, \"/account\")?;\n\n        let resp = client\n            .delete(&url)\n            .json(&serde_json::json!({ \"password\": password }))\n            .send()\n            .await?;\n\n        match resp.status().as_u16() {\n            200 => Ok(MutateResponse::Success),\n            401 => bail!(\"password is incorrect\"),\n            403 => bail!(\"invalid login details\"),\n            _ => bail!(\"unknown error\"),\n        }\n    }\n}\n\n// ---------------------------------------------------------------------------\n// Hub backend — talks to the Hub v0 API endpoints\n// ---------------------------------------------------------------------------\n\npub struct HubAuthClient {\n    address: String,\n    hub_token: Option<String>,\n}\n\nimpl HubAuthClient {\n    pub fn new(address: &str, hub_token: Option<String>) -> Self {\n        Self {\n            address: address.trim_end_matches('/').to_string(),\n            hub_token,\n        }\n    }\n}\n\n/// Hub v0 error/status response — includes an optional `code` field for\n/// machine-readable status like `\"2fa_required\"`.\n#[derive(Debug, Deserialize)]\nstruct HubErrorResponse {\n    reason: String,\n    code: Option<String>,\n}\n\n#[async_trait]\nimpl AuthClient for HubAuthClient {\n    async fn login(\n        &self,\n        username: &str,\n        password: &str,\n        totp_code: Option<&str>,\n    ) -> Result<AuthResponse> {\n        ensure_crypto_provider();\n        let url = make_url(&self.address, \"/api/v0/login\")?;\n        let client = reqwest::Client::new();\n\n        let mut body = serde_json::json!({\n            \"username\": username,\n            \"password\": password,\n        });\n        if let Some(code) = totp_code {\n            body[\"totp_code\"] = serde_json::Value::String(code.to_string());\n        }\n\n        let resp = client\n            .post(&url)\n            .header(USER_AGENT, APP_USER_AGENT)\n            .header(ATUIN_HEADER_VERSION, ATUIN_CARGO_VERSION)\n            .json(&body)\n            .send()\n            .await\n            .context(\"failed to connect to Atuin Hub\")?;\n\n        let status = resp.status();\n\n        if status.is_success() {\n            let login: LoginResponse = resp.json().await?;\n            return Ok(AuthResponse::Success {\n                session: login.session,\n            });\n        }\n\n        if status == StatusCode::FORBIDDEN\n            && let Ok(err) = resp.json::<HubErrorResponse>().await\n        {\n            if err.code.as_deref() == Some(\"2fa_required\") {\n                return Ok(AuthResponse::TwoFactorRequired);\n            }\n            bail!(\"{}\", err.reason);\n        }\n\n        if status == StatusCode::UNAUTHORIZED {\n            bail!(\"invalid credentials\");\n        }\n\n        bail!(\"Hub login failed with status {status}\");\n    }\n\n    async fn register(&self, username: &str, email: &str, password: &str) -> Result<AuthResponse> {\n        ensure_crypto_provider();\n        let url = make_url(&self.address, \"/api/v0/register\")?;\n        let client = reqwest::Client::new();\n\n        let resp = client\n            .post(&url)\n            .header(USER_AGENT, APP_USER_AGENT)\n            .header(ATUIN_HEADER_VERSION, ATUIN_CARGO_VERSION)\n            .json(&serde_json::json!({\n                \"email\": email,\n                \"username\": username,\n                \"password\": password,\n            }))\n            .send()\n            .await\n            .context(\"failed to connect to Atuin Hub\")?;\n\n        let status = resp.status();\n\n        if status.is_success() {\n            let reg: RegisterResponse = resp.json().await?;\n            return Ok(AuthResponse::Success {\n                session: reg.session,\n            });\n        }\n\n        if let Ok(err) = resp.json::<HubErrorResponse>().await {\n            bail!(\"{}\", err.reason);\n        }\n\n        bail!(\"Hub registration failed with status {status}\");\n    }\n\n    async fn change_password(\n        &self,\n        current_password: &str,\n        new_password: &str,\n        totp_code: Option<&str>,\n    ) -> Result<MutateResponse> {\n        let hub_token = self\n            .hub_token\n            .as_deref()\n            .ok_or_else(|| eyre::eyre!(\"Not logged in to Hub\"))?;\n\n        ensure_crypto_provider();\n        let url = make_url(&self.address, \"/api/v0/account/password\")?;\n        let client = reqwest::Client::new();\n\n        let mut body = serde_json::json!({\n            \"current_password\": current_password,\n            \"new_password\": new_password,\n        });\n        if let Some(code) = totp_code {\n            body[\"totp_code\"] = serde_json::Value::String(code.to_string());\n        }\n\n        let resp = client\n            .patch(&url)\n            .header(USER_AGENT, APP_USER_AGENT)\n            .header(ATUIN_HEADER_VERSION, ATUIN_CARGO_VERSION)\n            .bearer_auth(hub_token)\n            .json(&body)\n            .send()\n            .await\n            .context(\"failed to connect to Atuin Hub\")?;\n\n        let status = resp.status();\n\n        if status.is_success() {\n            return Ok(MutateResponse::Success);\n        }\n\n        if let Ok(err) = resp.json::<HubErrorResponse>().await {\n            match err.code.as_deref() {\n                Some(\"2fa_required\") => return Ok(MutateResponse::TwoFactorRequired),\n                Some(\"invalid_2fa_code\") => bail!(\"invalid two-factor code\"),\n                _ => bail!(\"{}\", err.reason),\n            }\n        }\n\n        match status {\n            StatusCode::UNAUTHORIZED => bail!(\"current password is incorrect\"),\n            StatusCode::FORBIDDEN => bail!(\"invalid login details\"),\n            _ => bail!(\"Hub password change failed with status {status}\"),\n        }\n    }\n\n    async fn delete_account(\n        &self,\n        password: &str,\n        totp_code: Option<&str>,\n    ) -> Result<MutateResponse> {\n        let hub_token = self\n            .hub_token\n            .as_deref()\n            .ok_or_else(|| eyre::eyre!(\"Not logged in to Hub\"))?;\n\n        ensure_crypto_provider();\n        let url = make_url(&self.address, \"/api/v0/account\")?;\n        let client = reqwest::Client::new();\n\n        let mut body = serde_json::json!({\n            \"password\": password,\n        });\n        if let Some(code) = totp_code {\n            body[\"totp_code\"] = serde_json::Value::String(code.to_string());\n        }\n\n        let resp = client\n            .delete(&url)\n            .header(USER_AGENT, APP_USER_AGENT)\n            .header(ATUIN_HEADER_VERSION, ATUIN_CARGO_VERSION)\n            .bearer_auth(hub_token)\n            .json(&body)\n            .send()\n            .await\n            .context(\"failed to connect to Atuin Hub\")?;\n\n        let status = resp.status();\n\n        if status.is_success() {\n            return Ok(MutateResponse::Success);\n        }\n\n        if let Ok(err) = resp.json::<HubErrorResponse>().await {\n            match err.code.as_deref() {\n                Some(\"2fa_required\") => return Ok(MutateResponse::TwoFactorRequired),\n                Some(\"invalid_2fa_code\") => bail!(\"invalid two-factor code\"),\n                _ => bail!(\"{}\", err.reason),\n            }\n        }\n\n        match status {\n            StatusCode::UNAUTHORIZED => bail!(\"password is incorrect\"),\n            StatusCode::FORBIDDEN => bail!(\"invalid login details\"),\n            _ => bail!(\"Hub account deletion failed with status {status}\"),\n        }\n    }\n}\n\n// ---------------------------------------------------------------------------\n// Shared helpers\n// ---------------------------------------------------------------------------\n\nfn make_url(address: &str, path: &str) -> Result<String> {\n    let address = if address.ends_with('/') {\n        address.to_string()\n    } else {\n        format!(\"{address}/\")\n    };\n\n    let path = path.strip_prefix('/').unwrap_or(path);\n\n    let url = Url::parse(&address)\n        .context(\"failed to parse server address\")?\n        .join(path)\n        .context(\"failed to join URL path\")?;\n\n    Ok(url.to_string())\n}\n"
  },
  {
    "path": "crates/atuin-client/src/database.rs",
    "content": "use std::{\n    env,\n    path::{Path, PathBuf},\n    str::FromStr,\n    time::Duration,\n};\n\nuse async_trait::async_trait;\nuse atuin_common::utils;\nuse fs_err as fs;\nuse itertools::Itertools;\nuse rand::{Rng, distributions::Alphanumeric};\nuse sql_builder::{SqlBuilder, SqlName, bind::Bind, esc, quote};\nuse sqlx::{\n    Result, Row,\n    sqlite::{\n        SqliteConnectOptions, SqliteJournalMode, SqlitePool, SqlitePoolOptions, SqliteRow,\n        SqliteSynchronous,\n    },\n};\nuse time::OffsetDateTime;\nuse uuid::Uuid;\n\nuse crate::{\n    history::{HistoryId, HistoryStats},\n    utils::get_host_user,\n};\n\nuse super::{\n    history::History,\n    ordering,\n    settings::{FilterMode, SearchMode, Settings},\n};\n\n#[derive(Clone)]\npub struct Context {\n    pub session: String,\n    pub cwd: String,\n    pub hostname: String,\n    pub host_id: String,\n    pub git_root: Option<PathBuf>,\n}\n\n#[derive(Default, Clone)]\npub struct OptFilters {\n    pub exit: Option<i64>,\n    pub exclude_exit: Option<i64>,\n    pub cwd: Option<String>,\n    pub exclude_cwd: Option<String>,\n    pub before: Option<String>,\n    pub after: Option<String>,\n    pub limit: Option<i64>,\n    pub offset: Option<i64>,\n    pub reverse: bool,\n    pub include_duplicates: bool,\n}\n\npub async fn current_context() -> eyre::Result<Context> {\n    let session = env::var(\"ATUIN_SESSION\").map_err(|_| {\n        eyre::eyre!(\"Failed to find $ATUIN_SESSION in the environment. Check that you have correctly set up your shell.\")\n    })?;\n    let hostname = get_host_user();\n    let cwd = utils::get_current_dir();\n    let host_id = Settings::host_id().await?;\n    let git_root = utils::in_git_repo(cwd.as_str());\n\n    Ok(Context {\n        session,\n        hostname,\n        cwd,\n        git_root,\n        host_id: host_id.0.as_simple().to_string(),\n    })\n}\n\nimpl Context {\n    pub fn from_history(entry: &History) -> Self {\n        Context {\n            session: entry.session.to_string(),\n            cwd: entry.cwd.to_string(),\n            hostname: entry.hostname.to_string(),\n            host_id: String::new(),\n            git_root: utils::in_git_repo(entry.cwd.as_str()),\n        }\n    }\n}\n\nfn get_session_start_time(session_id: &str) -> Option<i64> {\n    if let Ok(uuid) = Uuid::parse_str(session_id)\n        && let Some(timestamp) = uuid.get_timestamp()\n    {\n        let (seconds, nanos) = timestamp.to_unix();\n        return Some(seconds as i64 * 1_000_000_000 + nanos as i64);\n    }\n    None\n}\n\n#[async_trait]\npub trait Database: Send + Sync + 'static {\n    async fn save(&self, h: &History) -> Result<()>;\n    async fn save_bulk(&self, h: &[History]) -> Result<()>;\n\n    async fn load(&self, id: &str) -> Result<Option<History>>;\n    async fn list(\n        &self,\n        filters: &[FilterMode],\n        context: &Context,\n        max: Option<usize>,\n        unique: bool,\n        include_deleted: bool,\n    ) -> Result<Vec<History>>;\n    async fn range(&self, from: OffsetDateTime, to: OffsetDateTime) -> Result<Vec<History>>;\n\n    async fn update(&self, h: &History) -> Result<()>;\n    async fn history_count(&self, include_deleted: bool) -> Result<i64>;\n\n    async fn last(&self) -> Result<Option<History>>;\n    async fn before(&self, timestamp: OffsetDateTime, count: i64) -> Result<Vec<History>>;\n\n    async fn delete(&self, h: History) -> Result<()>;\n    async fn delete_rows(&self, ids: &[HistoryId]) -> Result<()>;\n    async fn deleted(&self) -> Result<Vec<History>>;\n\n    // Yes I know, it's a lot.\n    // Could maybe break it down to a searchparams struct or smth but that feels a little... pointless.\n    // Been debating maybe a DSL for search? eg \"before:time limit:1 the query\"\n    #[allow(clippy::too_many_arguments)]\n    async fn search(\n        &self,\n        search_mode: SearchMode,\n        filter: FilterMode,\n        context: &Context,\n        query: &str,\n        filter_options: OptFilters,\n    ) -> Result<Vec<History>>;\n\n    async fn query_history(&self, query: &str) -> Result<Vec<History>>;\n\n    async fn all_with_count(&self) -> Result<Vec<(History, i32)>>;\n\n    fn all_paged(&self, page_size: usize, include_deleted: bool, unique: bool) -> Paged;\n\n    async fn stats(&self, h: &History) -> Result<HistoryStats>;\n\n    async fn get_dups(&self, before: i64, dupkeep: u32) -> Result<Vec<History>>;\n\n    fn clone_boxed(&self) -> Box<dyn Database + 'static>;\n}\n\n// Intended for use on a developer machine and not a sync server.\n// TODO: implement IntoIterator\n#[derive(Debug, Clone)]\npub struct Sqlite {\n    pub pool: SqlitePool,\n}\n\nimpl Sqlite {\n    pub async fn new(path: impl AsRef<Path>, timeout: f64) -> Result<Self> {\n        let path = path.as_ref();\n        debug!(\"opening sqlite database at {path:?}\");\n\n        if utils::broken_symlink(path) {\n            eprintln!(\n                \"Atuin: Sqlite db path ({path:?}) is a broken symlink. Unable to read or create replacement.\"\n            );\n            std::process::exit(1);\n        }\n\n        if !path.exists()\n            && let Some(dir) = path.parent()\n        {\n            fs::create_dir_all(dir)?;\n        }\n\n        let opts = SqliteConnectOptions::from_str(path.as_os_str().to_str().unwrap())?\n            .journal_mode(SqliteJournalMode::Wal)\n            .optimize_on_close(true, None)\n            .synchronous(SqliteSynchronous::Normal)\n            .with_regexp()\n            .create_if_missing(true);\n\n        let pool = SqlitePoolOptions::new()\n            .acquire_timeout(Duration::from_secs_f64(timeout))\n            .connect_with(opts)\n            .await?;\n\n        Self::setup_db(&pool).await?;\n        Ok(Self { pool })\n    }\n\n    pub async fn sqlite_version(&self) -> Result<String> {\n        sqlx::query_scalar(\"SELECT sqlite_version()\")\n            .fetch_one(&self.pool)\n            .await\n    }\n\n    async fn setup_db(pool: &SqlitePool) -> Result<()> {\n        debug!(\"running sqlite database setup\");\n\n        sqlx::migrate!(\"./migrations\").run(pool).await?;\n\n        Ok(())\n    }\n\n    async fn save_raw(tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>, h: &History) -> Result<()> {\n        sqlx::query(\n            \"insert or ignore into history(id, timestamp, duration, exit, command, cwd, session, hostname, author, intent, deleted_at)\n                values(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)\",\n        )\n        .bind(h.id.0.as_str())\n        .bind(h.timestamp.unix_timestamp_nanos() as i64)\n        .bind(h.duration)\n        .bind(h.exit)\n        .bind(h.command.as_str())\n        .bind(h.cwd.as_str())\n        .bind(h.session.as_str())\n        .bind(h.hostname.as_str())\n        .bind(h.author.as_str())\n        .bind(h.intent.as_deref())\n        .bind(h.deleted_at.map(|t|t.unix_timestamp_nanos() as i64))\n        .execute(&mut **tx)\n        .await?;\n\n        Ok(())\n    }\n\n    async fn delete_row_raw(\n        tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,\n        id: HistoryId,\n    ) -> Result<()> {\n        sqlx::query(\"delete from history where id = ?1\")\n            .bind(id.0.as_str())\n            .execute(&mut **tx)\n            .await?;\n\n        Ok(())\n    }\n\n    fn query_history(row: SqliteRow) -> History {\n        let deleted_at: Option<i64> = row.get(\"deleted_at\");\n        let hostname: String = row.get(\"hostname\");\n        let author: Option<String> = row.try_get(\"author\").ok().flatten();\n        let author = author\n            .filter(|author| !author.trim().is_empty())\n            .unwrap_or_else(|| History::author_from_hostname(hostname.as_str()));\n        let intent: Option<String> = row.try_get(\"intent\").ok().flatten();\n        let intent = intent.filter(|intent| !intent.trim().is_empty());\n\n        History::from_db()\n            .id(row.get(\"id\"))\n            .timestamp(\n                OffsetDateTime::from_unix_timestamp_nanos(row.get::<i64, _>(\"timestamp\") as i128)\n                    .unwrap(),\n            )\n            .duration(row.get(\"duration\"))\n            .exit(row.get(\"exit\"))\n            .command(row.get(\"command\"))\n            .cwd(row.get(\"cwd\"))\n            .session(row.get(\"session\"))\n            .hostname(hostname)\n            .author(author)\n            .intent(intent)\n            .deleted_at(\n                deleted_at.and_then(|t| OffsetDateTime::from_unix_timestamp_nanos(t as i128).ok()),\n            )\n            .build()\n            .into()\n    }\n}\n\n#[async_trait]\nimpl Database for Sqlite {\n    async fn save(&self, h: &History) -> Result<()> {\n        debug!(\"saving history to sqlite\");\n        let mut tx = self.pool.begin().await?;\n        Self::save_raw(&mut tx, h).await?;\n        tx.commit().await?;\n\n        Ok(())\n    }\n\n    async fn save_bulk(&self, h: &[History]) -> Result<()> {\n        debug!(\"saving history to sqlite\");\n\n        let mut tx = self.pool.begin().await?;\n\n        for i in h {\n            Self::save_raw(&mut tx, i).await?;\n        }\n\n        tx.commit().await?;\n\n        Ok(())\n    }\n\n    async fn load(&self, id: &str) -> Result<Option<History>> {\n        debug!(\"loading history item {}\", id);\n\n        let res = sqlx::query(\"select * from history where id = ?1\")\n            .bind(id)\n            .map(Self::query_history)\n            .fetch_optional(&self.pool)\n            .await?;\n\n        Ok(res)\n    }\n\n    async fn update(&self, h: &History) -> Result<()> {\n        debug!(\"updating sqlite history\");\n\n        sqlx::query(\n            \"update history\n                set timestamp = ?2, duration = ?3, exit = ?4, command = ?5, cwd = ?6, session = ?7, hostname = ?8, author = ?9, intent = ?10, deleted_at = ?11\n                where id = ?1\",\n        )\n        .bind(h.id.0.as_str())\n        .bind(h.timestamp.unix_timestamp_nanos() as i64)\n        .bind(h.duration)\n        .bind(h.exit)\n        .bind(h.command.as_str())\n        .bind(h.cwd.as_str())\n        .bind(h.session.as_str())\n        .bind(h.hostname.as_str())\n        .bind(h.author.as_str())\n        .bind(h.intent.as_deref())\n        .bind(h.deleted_at.map(|t|t.unix_timestamp_nanos() as i64))\n        .execute(&self.pool)\n        .await?;\n\n        Ok(())\n    }\n\n    // make a unique list, that only shows the *newest* version of things\n    async fn list(\n        &self,\n        filters: &[FilterMode],\n        context: &Context,\n        max: Option<usize>,\n        unique: bool,\n        include_deleted: bool,\n    ) -> Result<Vec<History>> {\n        debug!(\"listing history\");\n\n        let mut query = SqlBuilder::select_from(SqlName::new(\"history\").alias(\"h\").baquoted());\n        query.field(\"*\").order_desc(\"timestamp\");\n        if !include_deleted {\n            query.and_where_is_null(\"deleted_at\");\n        }\n\n        let git_root = if let Some(git_root) = context.git_root.clone() {\n            git_root.to_str().unwrap_or(\"/\").to_string()\n        } else {\n            context.cwd.clone()\n        };\n\n        let session_start = get_session_start_time(&context.session);\n\n        for filter in filters {\n            match filter {\n                FilterMode::Global => &mut query,\n                FilterMode::Host => query.and_where_eq(\"hostname\", quote(&context.hostname)),\n                FilterMode::Session => query.and_where_eq(\"session\", quote(&context.session)),\n                FilterMode::SessionPreload => {\n                    query.and_where_eq(\"session\", quote(&context.session));\n                    if let Some(session_start) = session_start {\n                        query.or_where_lt(\"timestamp\", session_start);\n                    }\n                    &mut query\n                }\n                FilterMode::Directory => query.and_where_eq(\"cwd\", quote(&context.cwd)),\n                FilterMode::Workspace => query.and_where_like_left(\"cwd\", &git_root),\n            };\n        }\n\n        if unique {\n            query.group_by(\"command\").having(\"max(timestamp)\");\n        }\n\n        if let Some(max) = max {\n            query.limit(max);\n        }\n\n        let query = query.sql().expect(\"bug in list query. please report\");\n\n        let res = sqlx::query(&query)\n            .map(Self::query_history)\n            .fetch_all(&self.pool)\n            .await?;\n\n        Ok(res)\n    }\n\n    async fn range(&self, from: OffsetDateTime, to: OffsetDateTime) -> Result<Vec<History>> {\n        debug!(\"listing history from {:?} to {:?}\", from, to);\n\n        let res = sqlx::query(\n            \"select * from history where timestamp >= ?1 and timestamp <= ?2 order by timestamp asc\",\n        )\n        .bind(from.unix_timestamp_nanos() as i64)\n        .bind(to.unix_timestamp_nanos() as i64)\n            .map(Self::query_history)\n        .fetch_all(&self.pool)\n        .await?;\n\n        Ok(res)\n    }\n\n    async fn last(&self) -> Result<Option<History>> {\n        let res = sqlx::query(\n            \"select * from history where duration >= 0 order by timestamp desc limit 1\",\n        )\n        .map(Self::query_history)\n        .fetch_optional(&self.pool)\n        .await?;\n\n        Ok(res)\n    }\n\n    async fn before(&self, timestamp: OffsetDateTime, count: i64) -> Result<Vec<History>> {\n        let res = sqlx::query(\n            \"select * from history where timestamp < ?1 order by timestamp desc limit ?2\",\n        )\n        .bind(timestamp.unix_timestamp_nanos() as i64)\n        .bind(count)\n        .map(Self::query_history)\n        .fetch_all(&self.pool)\n        .await?;\n\n        Ok(res)\n    }\n\n    async fn deleted(&self) -> Result<Vec<History>> {\n        let res = sqlx::query(\"select * from history where deleted_at is not null\")\n            .map(Self::query_history)\n            .fetch_all(&self.pool)\n            .await?;\n\n        Ok(res)\n    }\n\n    async fn history_count(&self, include_deleted: bool) -> Result<i64> {\n        let query = if include_deleted {\n            \"select count(1) from history\"\n        } else {\n            \"select count(1) from history where deleted_at is null\"\n        };\n\n        let res: (i64,) = sqlx::query_as(query).fetch_one(&self.pool).await?;\n        Ok(res.0)\n    }\n\n    async fn search(\n        &self,\n        search_mode: SearchMode,\n        filter: FilterMode,\n        context: &Context,\n        query: &str,\n        filter_options: OptFilters,\n    ) -> Result<Vec<History>> {\n        let mut sql = SqlBuilder::select_from(\"history\");\n\n        if !filter_options.include_duplicates {\n            sql.group_by(\"command\").having(\"max(timestamp)\");\n        }\n\n        if let Some(limit) = filter_options.limit {\n            sql.limit(limit);\n        }\n\n        if let Some(offset) = filter_options.offset {\n            sql.offset(offset);\n        }\n\n        if filter_options.reverse {\n            sql.order_asc(\"timestamp\");\n        } else {\n            sql.order_desc(\"timestamp\");\n        }\n\n        let git_root = if let Some(git_root) = context.git_root.clone() {\n            git_root.to_str().unwrap_or(\"/\").to_string()\n        } else {\n            context.cwd.clone()\n        };\n\n        let session_start = get_session_start_time(&context.session);\n\n        match filter {\n            FilterMode::Global => &mut sql,\n            FilterMode::Host => {\n                sql.and_where_eq(\"lower(hostname)\", quote(context.hostname.to_lowercase()))\n            }\n            FilterMode::Session => sql.and_where_eq(\"session\", quote(&context.session)),\n            FilterMode::SessionPreload => {\n                sql.and_where_eq(\"session\", quote(&context.session));\n                if let Some(session_start) = session_start {\n                    sql.or_where_lt(\"timestamp\", session_start);\n                }\n                &mut sql\n            }\n            FilterMode::Directory => sql.and_where_eq(\"cwd\", quote(&context.cwd)),\n            FilterMode::Workspace => sql.and_where_like_left(\"cwd\", git_root),\n        };\n\n        let orig_query = query;\n\n        let mut regexes = Vec::new();\n        match search_mode {\n            SearchMode::Prefix => sql.and_where_like_left(\"command\", query.replace('*', \"%\")),\n            _ => {\n                let mut is_or = false;\n                for token in QueryTokenizer::new(query) {\n                    // TODO smart case mode could be made configurable like in fzf\n                    let (is_glob, glob) = if token.has_uppercase() {\n                        (true, \"*\")\n                    } else {\n                        (false, \"%\")\n                    };\n                    let param = match token {\n                        QueryToken::Regex(r) => {\n                            regexes.push(String::from(r));\n                            continue;\n                        }\n                        QueryToken::Or => {\n                            if !is_or {\n                                is_or = true;\n                                continue;\n                            } else {\n                                format!(\"{glob}|{glob}\")\n                            }\n                        }\n                        QueryToken::MatchStart(term, _) => {\n                            format!(\"{term}{glob}\")\n                        }\n                        QueryToken::MatchEnd(term, _) => {\n                            format!(\"{glob}{term}\")\n                        }\n                        QueryToken::MatchFull(term, _) => {\n                            format!(\"{glob}{term}{glob}\")\n                        }\n                        QueryToken::Match(term, _) => {\n                            if search_mode == SearchMode::FullText {\n                                format!(\"{glob}{term}{glob}\")\n                            } else {\n                                term.split(\"\").join(glob)\n                            }\n                        }\n                    };\n\n                    sql.fuzzy_condition(\"command\", param, token.is_inverse(), is_glob, is_or);\n                    is_or = false;\n                }\n\n                &mut sql\n            }\n        };\n\n        for regex in regexes {\n            sql.and_where(\"command regexp ?\".bind(&regex));\n        }\n\n        filter_options\n            .exit\n            .map(|exit| sql.and_where_eq(\"exit\", exit));\n\n        filter_options\n            .exclude_exit\n            .map(|exclude_exit| sql.and_where_ne(\"exit\", exclude_exit));\n\n        filter_options\n            .cwd\n            .map(|cwd| sql.and_where_eq(\"cwd\", quote(cwd)));\n\n        filter_options\n            .exclude_cwd\n            .map(|exclude_cwd| sql.and_where_ne(\"cwd\", quote(exclude_cwd)));\n\n        filter_options.before.map(|before| {\n            interim::parse_date_string(\n                before.as_str(),\n                OffsetDateTime::now_utc(),\n                interim::Dialect::Uk,\n            )\n            .map(|before| {\n                sql.and_where_lt(\"timestamp\", quote(before.unix_timestamp_nanos() as i64))\n            })\n        });\n\n        filter_options.after.map(|after| {\n            interim::parse_date_string(\n                after.as_str(),\n                OffsetDateTime::now_utc(),\n                interim::Dialect::Uk,\n            )\n            .map(|after| sql.and_where_gt(\"timestamp\", quote(after.unix_timestamp_nanos() as i64)))\n        });\n\n        sql.and_where_is_null(\"deleted_at\");\n\n        let query = sql.sql().expect(\"bug in search query. please report\");\n\n        let res = sqlx::query(&query)\n            .map(Self::query_history)\n            .fetch_all(&self.pool)\n            .await?;\n\n        Ok(ordering::reorder_fuzzy(search_mode, orig_query, res))\n    }\n\n    async fn query_history(&self, query: &str) -> Result<Vec<History>> {\n        let res = sqlx::query(query)\n            .map(Self::query_history)\n            .fetch_all(&self.pool)\n            .await?;\n\n        Ok(res)\n    }\n\n    async fn all_with_count(&self) -> Result<Vec<(History, i32)>> {\n        debug!(\"listing history\");\n\n        let mut query = SqlBuilder::select_from(SqlName::new(\"history\").alias(\"h\").baquoted());\n\n        query\n            .fields(&[\n                \"id\",\n                \"max(timestamp) as timestamp\",\n                \"max(duration) as duration\",\n                \"exit\",\n                \"command\",\n                \"deleted_at\",\n                \"null as author\",\n                \"null as intent\",\n                \"group_concat(cwd, ':') as cwd\",\n                \"group_concat(session) as session\",\n                \"group_concat(hostname, ',') as hostname\",\n                \"count(*) as count\",\n            ])\n            .group_by(\"command\")\n            .group_by(\"exit\")\n            .and_where(\"deleted_at is null\")\n            .order_desc(\"timestamp\");\n\n        let query = query.sql().expect(\"bug in list query. please report\");\n\n        let res = sqlx::query(&query)\n            .map(|row: SqliteRow| {\n                let count: i32 = row.get(\"count\");\n                (Self::query_history(row), count)\n            })\n            .fetch_all(&self.pool)\n            .await?;\n\n        Ok(res)\n    }\n\n    fn all_paged(&self, page_size: usize, include_deleted: bool, unique: bool) -> Paged {\n        Paged::new(Box::new(self.clone()), page_size, include_deleted, unique)\n    }\n\n    // deleted_at doesn't mean the actual time that the user deleted it,\n    // but the time that the system marks it as deleted\n    async fn delete(&self, mut h: History) -> Result<()> {\n        let now = OffsetDateTime::now_utc();\n        h.command = rand::thread_rng()\n            .sample_iter(&Alphanumeric)\n            .take(32)\n            .map(char::from)\n            .collect(); // overwrite with random string\n        h.deleted_at = Some(now); // delete it\n\n        self.update(&h).await?; // save it\n\n        Ok(())\n    }\n\n    async fn delete_rows(&self, ids: &[HistoryId]) -> Result<()> {\n        let mut tx = self.pool.begin().await?;\n\n        for id in ids {\n            Self::delete_row_raw(&mut tx, id.clone()).await?;\n        }\n\n        tx.commit().await?;\n\n        Ok(())\n    }\n\n    async fn stats(&self, h: &History) -> Result<HistoryStats> {\n        // We select the previous in the session by time\n        let mut prev = SqlBuilder::select_from(\"history\");\n        prev.field(\"*\")\n            .and_where(\"timestamp < ?1\")\n            .and_where(\"session = ?2\")\n            .order_by(\"timestamp\", true)\n            .limit(1);\n\n        let mut next = SqlBuilder::select_from(\"history\");\n        next.field(\"*\")\n            .and_where(\"timestamp > ?1\")\n            .and_where(\"session = ?2\")\n            .order_by(\"timestamp\", false)\n            .limit(1);\n\n        let mut total = SqlBuilder::select_from(\"history\");\n        total.field(\"count(1)\").and_where(\"command = ?1\");\n\n        let mut average = SqlBuilder::select_from(\"history\");\n        average.field(\"avg(duration)\").and_where(\"command = ?1\");\n\n        let mut exits = SqlBuilder::select_from(\"history\");\n        exits\n            .fields(&[\"exit\", \"count(1) as count\"])\n            .and_where(\"command = ?1\")\n            .group_by(\"exit\");\n\n        // rewrite the following with sqlbuilder\n        let mut day_of_week = SqlBuilder::select_from(\"history\");\n        day_of_week\n            .fields(&[\n                \"strftime('%w', ROUND(timestamp / 1000000000), 'unixepoch') AS day_of_week\",\n                \"count(1) as count\",\n            ])\n            .and_where(\"command = ?1\")\n            .group_by(\"day_of_week\");\n\n        // Intentionally format the string with 01 hardcoded. We want the average runtime for the\n        // _entire month_, but will later parse it as a datetime for sorting\n        // Sqlite has no datetime so we cannot do it there, and otherwise sorting will just be a\n        // string sort, which won't be correct.\n        let mut duration_over_time = SqlBuilder::select_from(\"history\");\n        duration_over_time\n            .fields(&[\n                \"strftime('01-%m-%Y', ROUND(timestamp / 1000000000), 'unixepoch') AS month_year\",\n                \"avg(duration) as duration\",\n            ])\n            .and_where(\"command = ?1\")\n            .group_by(\"month_year\")\n            .having(\"duration > 0\");\n\n        let prev = prev.sql().expect(\"issue in stats previous query\");\n        let next = next.sql().expect(\"issue in stats next query\");\n        let total = total.sql().expect(\"issue in stats average query\");\n        let average = average.sql().expect(\"issue in stats previous query\");\n        let exits = exits.sql().expect(\"issue in stats exits query\");\n        let day_of_week = day_of_week.sql().expect(\"issue in stats day of week query\");\n        let duration_over_time = duration_over_time\n            .sql()\n            .expect(\"issue in stats duration over time query\");\n\n        let prev = sqlx::query(&prev)\n            .bind(h.timestamp.unix_timestamp_nanos() as i64)\n            .bind(&h.session)\n            .map(Self::query_history)\n            .fetch_optional(&self.pool)\n            .await?;\n\n        let next = sqlx::query(&next)\n            .bind(h.timestamp.unix_timestamp_nanos() as i64)\n            .bind(&h.session)\n            .map(Self::query_history)\n            .fetch_optional(&self.pool)\n            .await?;\n\n        let total: (i64,) = sqlx::query_as(&total)\n            .bind(&h.command)\n            .fetch_one(&self.pool)\n            .await?;\n\n        let average: (f64,) = sqlx::query_as(&average)\n            .bind(&h.command)\n            .fetch_one(&self.pool)\n            .await?;\n\n        let exits: Vec<(i64, i64)> = sqlx::query_as(&exits)\n            .bind(&h.command)\n            .fetch_all(&self.pool)\n            .await?;\n\n        let day_of_week: Vec<(String, i64)> = sqlx::query_as(&day_of_week)\n            .bind(&h.command)\n            .fetch_all(&self.pool)\n            .await?;\n\n        let duration_over_time: Vec<(String, f64)> = sqlx::query_as(&duration_over_time)\n            .bind(&h.command)\n            .fetch_all(&self.pool)\n            .await?;\n\n        let duration_over_time = duration_over_time\n            .iter()\n            .map(|f| (f.0.clone(), f.1.round() as i64))\n            .collect();\n\n        Ok(HistoryStats {\n            next,\n            previous: prev,\n            total: total.0 as u64,\n            average_duration: average.0 as u64,\n            exits,\n            day_of_week,\n            duration_over_time,\n        })\n    }\n\n    async fn get_dups(&self, before: i64, dupkeep: u32) -> Result<Vec<History>> {\n        let res = sqlx::query(\n            \"SELECT * FROM (\n                SELECT *, ROW_NUMBER()\n                  OVER (PARTITION BY command, cwd, hostname ORDER BY timestamp DESC)\n                  AS rn\n                  FROM history\n                ) sub\n              WHERE rn > ?1 and timestamp < ?2;\n            \",\n        )\n        .bind(dupkeep)\n        .bind(before)\n        .map(Self::query_history)\n        .fetch_all(&self.pool)\n        .await?;\n\n        Ok(res)\n    }\n\n    fn clone_boxed(&self) -> Box<dyn Database + 'static> {\n        Box::new(self.clone())\n    }\n}\n\npub struct Paged {\n    database: Box<dyn Database + 'static>,\n    page_size: usize,\n    last_id: Option<String>,\n    include_deleted: bool,\n    unique: bool,\n}\n\nimpl Paged {\n    pub fn new(\n        database: Box<dyn Database + 'static>,\n        page_size: usize,\n        include_deleted: bool,\n        unique: bool,\n    ) -> Self {\n        Self {\n            database,\n            page_size,\n            last_id: None,\n            include_deleted,\n            unique,\n        }\n    }\n\n    pub async fn next(&mut self) -> Result<Option<Vec<History>>> {\n        let mut query = SqlBuilder::select_from(SqlName::new(\"history\").alias(\"h\").baquoted());\n\n        query.field(\"*\").order_desc(\"id\");\n\n        if !self.include_deleted {\n            query.and_where_is_null(\"deleted_at\");\n        }\n\n        if self.unique {\n            // We want to deduplicate on command, but the user can search via cwd, hostname, and session.\n            // Without those fields, filter modes won't work right. With those fields, we get duplicates.\n            // This must be handled upstream.\n            query\n                .group_by(\"command, cwd, hostname, session\")\n                .having(\"max(timestamp)\");\n        }\n\n        query.limit(self.page_size);\n\n        if let Some(last_id) = &self.last_id {\n            query.and_where_lt(\"id\", quote(last_id));\n        }\n\n        let query = query.sql().expect(\"bug in list query. please report\");\n        let res = self.database.query_history(&query).await?;\n\n        if res.is_empty() {\n            Ok(None)\n        } else {\n            self.last_id = Some(res.last().unwrap().id.0.clone());\n            Ok(Some(res))\n        }\n    }\n}\n\ntrait SqlBuilderExt {\n    fn fuzzy_condition<S: ToString, T: ToString>(\n        &mut self,\n        field: S,\n        mask: T,\n        inverse: bool,\n        glob: bool,\n        is_or: bool,\n    ) -> &mut Self;\n}\n\nimpl SqlBuilderExt for SqlBuilder {\n    /// adapted from the sql-builder *like functions\n    fn fuzzy_condition<S: ToString, T: ToString>(\n        &mut self,\n        field: S,\n        mask: T,\n        inverse: bool,\n        glob: bool,\n        is_or: bool,\n    ) -> &mut Self {\n        let mut cond = field.to_string();\n        if inverse {\n            cond.push_str(\" NOT\");\n        }\n        if glob {\n            cond.push_str(\" GLOB '\");\n        } else {\n            cond.push_str(\" LIKE '\");\n        }\n        cond.push_str(&esc(mask.to_string()));\n        cond.push('\\'');\n        if is_or {\n            self.or_where(cond)\n        } else {\n            self.and_where(cond)\n        }\n    }\n}\n\n#[cfg(test)]\nmod test {\n    use crate::settings::test_local_timeout;\n\n    use super::*;\n    use std::time::{Duration, Instant};\n\n    async fn assert_search_eq(\n        db: &impl Database,\n        mode: SearchMode,\n        filter_mode: FilterMode,\n        query: &str,\n        expected: usize,\n    ) -> Result<Vec<History>> {\n        let context = Context {\n            hostname: \"test:host\".to_string(),\n            session: \"beepboopiamasession\".to_string(),\n            cwd: \"/home/ellie\".to_string(),\n            host_id: \"test-host\".to_string(),\n            git_root: None,\n        };\n\n        let results = db\n            .search(\n                mode,\n                filter_mode,\n                &context,\n                query,\n                OptFilters {\n                    ..Default::default()\n                },\n            )\n            .await?;\n\n        assert_eq!(\n            results.len(),\n            expected,\n            \"query \\\"{}\\\", commands: {:?}\",\n            query,\n            results.iter().map(|a| &a.command).collect::<Vec<&String>>()\n        );\n        Ok(results)\n    }\n\n    async fn assert_search_commands(\n        db: &impl Database,\n        mode: SearchMode,\n        filter_mode: FilterMode,\n        query: &str,\n        expected_commands: Vec<&str>,\n    ) {\n        let results = assert_search_eq(db, mode, filter_mode, query, expected_commands.len())\n            .await\n            .unwrap();\n        let commands: Vec<&str> = results.iter().map(|a| a.command.as_str()).collect();\n        assert_eq!(commands, expected_commands);\n    }\n\n    async fn new_history_item(db: &mut impl Database, cmd: &str) -> Result<()> {\n        let mut captured: History = History::capture()\n            .timestamp(OffsetDateTime::now_utc())\n            .command(cmd)\n            .cwd(\"/home/ellie\")\n            .build()\n            .into();\n\n        captured.exit = 0;\n        captured.duration = 1;\n        captured.session = \"beep boop\".to_string();\n        captured.hostname = \"booop\".to_string();\n\n        db.save(&captured).await\n    }\n\n    #[tokio::test(flavor = \"multi_thread\")]\n    async fn test_search_prefix() {\n        let mut db = Sqlite::new(\"sqlite::memory:\", test_local_timeout())\n            .await\n            .unwrap();\n        new_history_item(&mut db, \"ls /home/ellie\").await.unwrap();\n\n        assert_search_eq(&db, SearchMode::Prefix, FilterMode::Global, \"ls\", 1)\n            .await\n            .unwrap();\n        assert_search_eq(&db, SearchMode::Prefix, FilterMode::Global, \"/home\", 0)\n            .await\n            .unwrap();\n        assert_search_eq(&db, SearchMode::Prefix, FilterMode::Global, \"ls  \", 0)\n            .await\n            .unwrap();\n    }\n\n    #[tokio::test(flavor = \"multi_thread\")]\n    async fn test_search_fulltext() {\n        let mut db = Sqlite::new(\"sqlite::memory:\", test_local_timeout())\n            .await\n            .unwrap();\n        new_history_item(&mut db, \"ls /home/ellie\").await.unwrap();\n\n        assert_search_eq(&db, SearchMode::FullText, FilterMode::Global, \"ls\", 1)\n            .await\n            .unwrap();\n        assert_search_eq(&db, SearchMode::FullText, FilterMode::Global, \"/home\", 1)\n            .await\n            .unwrap();\n        assert_search_eq(&db, SearchMode::FullText, FilterMode::Global, \"ls ho\", 1)\n            .await\n            .unwrap();\n        assert_search_eq(&db, SearchMode::FullText, FilterMode::Global, \"hm\", 0)\n            .await\n            .unwrap();\n\n        // regex\n        assert_search_eq(&db, SearchMode::FullText, FilterMode::Global, \"r/^ls \", 1)\n            .await\n            .unwrap();\n        assert_search_eq(\n            &db,\n            SearchMode::FullText,\n            FilterMode::Global,\n            \"r/ls / ie$\",\n            1,\n        )\n        .await\n        .unwrap();\n        assert_search_eq(\n            &db,\n            SearchMode::FullText,\n            FilterMode::Global,\n            \"r/ls / !ie\",\n            0,\n        )\n        .await\n        .unwrap();\n        assert_search_eq(\n            &db,\n            SearchMode::FullText,\n            FilterMode::Global,\n            \"meow r/ls/\",\n            0,\n        )\n        .await\n        .unwrap();\n        assert_search_eq(&db, SearchMode::FullText, FilterMode::Global, \"r//hom/\", 1)\n            .await\n            .unwrap();\n        assert_search_eq(\n            &db,\n            SearchMode::FullText,\n            FilterMode::Global,\n            \"r//home//\",\n            1,\n        )\n        .await\n        .unwrap();\n        assert_search_eq(\n            &db,\n            SearchMode::FullText,\n            FilterMode::Global,\n            \"r//home///\",\n            0,\n        )\n        .await\n        .unwrap();\n        assert_search_eq(&db, SearchMode::FullText, FilterMode::Global, \"/home.*e\", 0)\n            .await\n            .unwrap();\n        assert_search_eq(\n            &db,\n            SearchMode::FullText,\n            FilterMode::Global,\n            \"r/home.*e\",\n            1,\n        )\n        .await\n        .unwrap();\n    }\n\n    #[tokio::test(flavor = \"multi_thread\")]\n    async fn test_search_fuzzy() {\n        let mut db = Sqlite::new(\"sqlite::memory:\", test_local_timeout())\n            .await\n            .unwrap();\n        new_history_item(&mut db, \"ls /home/ellie\").await.unwrap();\n        new_history_item(&mut db, \"ls /home/frank\").await.unwrap();\n        new_history_item(&mut db, \"cd /home/Ellie\").await.unwrap();\n        new_history_item(&mut db, \"/home/ellie/.bin/rustup\")\n            .await\n            .unwrap();\n\n        assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, \"ls /\", 3)\n            .await\n            .unwrap();\n        assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, \"ls/\", 2)\n            .await\n            .unwrap();\n        assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, \"l/h/\", 2)\n            .await\n            .unwrap();\n        assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, \"/h/e\", 3)\n            .await\n            .unwrap();\n        assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, \"/hmoe/\", 0)\n            .await\n            .unwrap();\n        assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, \"ellie/home\", 0)\n            .await\n            .unwrap();\n        assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, \"lsellie\", 1)\n            .await\n            .unwrap();\n        assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, \" \", 4)\n            .await\n            .unwrap();\n\n        // single term operators\n        assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, \"^ls\", 2)\n            .await\n            .unwrap();\n        assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, \"'ls\", 2)\n            .await\n            .unwrap();\n        assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, \"ellie$\", 2)\n            .await\n            .unwrap();\n        assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, \"!^ls\", 2)\n            .await\n            .unwrap();\n        assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, \"!ellie\", 1)\n            .await\n            .unwrap();\n        assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, \"!ellie$\", 2)\n            .await\n            .unwrap();\n\n        // multiple terms\n        assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, \"ls !ellie\", 1)\n            .await\n            .unwrap();\n        assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, \"^ls !e$\", 1)\n            .await\n            .unwrap();\n        assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, \"home !^ls\", 2)\n            .await\n            .unwrap();\n        assert_search_eq(\n            &db,\n            SearchMode::Fuzzy,\n            FilterMode::Global,\n            \"'frank | 'rustup\",\n            2,\n        )\n        .await\n        .unwrap();\n        assert_search_eq(\n            &db,\n            SearchMode::Fuzzy,\n            FilterMode::Global,\n            \"'frank | 'rustup 'ls\",\n            1,\n        )\n        .await\n        .unwrap();\n\n        // case matching\n        assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, \"Ellie\", 1)\n            .await\n            .unwrap();\n\n        // regex\n        assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, \"r/^ls \", 2)\n            .await\n            .unwrap();\n        assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, \"r/[Ee]llie\", 3)\n            .await\n            .unwrap();\n        assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, \"/h/e r/^ls \", 1)\n            .await\n            .unwrap();\n    }\n\n    #[tokio::test(flavor = \"multi_thread\")]\n    async fn test_search_reordered_fuzzy() {\n        let mut db = Sqlite::new(\"sqlite::memory:\", test_local_timeout())\n            .await\n            .unwrap();\n        // test ordering of results: we should choose the first, even though it happened longer ago.\n\n        new_history_item(&mut db, \"curl\").await.unwrap();\n        new_history_item(&mut db, \"corburl\").await.unwrap();\n\n        // if fuzzy reordering is on, it should come back in a more sensible order\n        assert_search_commands(\n            &db,\n            SearchMode::Fuzzy,\n            FilterMode::Global,\n            \"curl\",\n            vec![\"curl\", \"corburl\"],\n        )\n        .await;\n\n        assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, \"xxxx\", 0)\n            .await\n            .unwrap();\n        assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, \"\", 2)\n            .await\n            .unwrap();\n    }\n\n    #[tokio::test(flavor = \"multi_thread\")]\n    async fn test_paged_basic() {\n        let mut db = Sqlite::new(\"sqlite::memory:\", test_local_timeout())\n            .await\n            .unwrap();\n\n        // Add 5 history items\n        for i in 0..5 {\n            new_history_item(&mut db, &format!(\"command{}\", i))\n                .await\n                .unwrap();\n        }\n\n        // Create a paged iterator with page_size of 2\n        let mut paged = db.all_paged(2, false, false);\n\n        // First page should have 2 items\n        let page1 = paged.next().await.unwrap();\n        assert!(page1.is_some());\n        assert_eq!(page1.unwrap().len(), 2);\n\n        // Second page should have 2 items\n        let page2 = paged.next().await.unwrap();\n        assert!(page2.is_some());\n        assert_eq!(page2.unwrap().len(), 2);\n\n        // Third page should have 1 item\n        let page3 = paged.next().await.unwrap();\n        assert!(page3.is_some());\n        assert_eq!(page3.unwrap().len(), 1);\n\n        // Fourth page should be None (exhausted)\n        let page4 = paged.next().await.unwrap();\n        assert!(page4.is_none());\n    }\n\n    #[tokio::test(flavor = \"multi_thread\")]\n    async fn test_paged_empty() {\n        let db = Sqlite::new(\"sqlite::memory:\", test_local_timeout())\n            .await\n            .unwrap();\n\n        // Create a paged iterator on empty database\n        let mut paged = db.all_paged(10, false, false);\n\n        // Should return None immediately\n        let page = paged.next().await.unwrap();\n        assert!(page.is_none());\n    }\n\n    #[tokio::test(flavor = \"multi_thread\")]\n    async fn test_paged_unique() {\n        let mut db = Sqlite::new(\"sqlite::memory:\", test_local_timeout())\n            .await\n            .unwrap();\n\n        // Add duplicate commands\n        new_history_item(&mut db, \"duplicate\").await.unwrap();\n        new_history_item(&mut db, \"duplicate\").await.unwrap();\n        new_history_item(&mut db, \"unique1\").await.unwrap();\n        new_history_item(&mut db, \"unique2\").await.unwrap();\n\n        // Without unique flag - should get all 4\n        let mut paged = db.all_paged(10, false, false);\n        let page = paged.next().await.unwrap().unwrap();\n        assert_eq!(page.len(), 4);\n\n        // With unique flag - should get 3 (duplicates collapsed)\n        let mut paged_unique = db.all_paged(10, false, true);\n        let page_unique = paged_unique.next().await.unwrap().unwrap();\n        assert_eq!(page_unique.len(), 3);\n    }\n\n    #[tokio::test(flavor = \"multi_thread\")]\n    async fn test_paged_include_deleted() {\n        let mut db = Sqlite::new(\"sqlite::memory:\", test_local_timeout())\n            .await\n            .unwrap();\n\n        // Add items\n        new_history_item(&mut db, \"keep1\").await.unwrap();\n        new_history_item(&mut db, \"keep2\").await.unwrap();\n        new_history_item(&mut db, \"delete_me\").await.unwrap();\n\n        // Delete one item\n        let all = db\n            .list(\n                &[],\n                &Context {\n                    hostname: \"\".to_string(),\n                    session: \"\".to_string(),\n                    cwd: \"\".to_string(),\n                    host_id: \"\".to_string(),\n                    git_root: None,\n                },\n                None,\n                false,\n                false,\n            )\n            .await\n            .unwrap();\n\n        let to_delete = all\n            .iter()\n            .find(|h| h.command == \"delete_me\")\n            .unwrap()\n            .clone();\n        db.delete(to_delete).await.unwrap();\n\n        // Without include_deleted - should get 2\n        let mut paged = db.all_paged(10, false, false);\n        let page = paged.next().await.unwrap().unwrap();\n        assert_eq!(page.len(), 2);\n\n        // With include_deleted - should get 3\n        let mut paged_deleted = db.all_paged(10, true, false);\n        let page_deleted = paged_deleted.next().await.unwrap().unwrap();\n        assert_eq!(page_deleted.len(), 3);\n    }\n\n    #[tokio::test(flavor = \"multi_thread\")]\n    async fn test_search_bench_dupes() {\n        let context = Context {\n            hostname: \"test:host\".to_string(),\n            session: \"beepboopiamasession\".to_string(),\n            cwd: \"/home/ellie\".to_string(),\n            host_id: \"test-host\".to_string(),\n            git_root: None,\n        };\n\n        let mut db = Sqlite::new(\"sqlite::memory:\", test_local_timeout())\n            .await\n            .unwrap();\n        for _i in 1..10000 {\n            new_history_item(&mut db, \"i am a duplicated command\")\n                .await\n                .unwrap();\n        }\n        let start = Instant::now();\n        let _results = db\n            .search(\n                SearchMode::Fuzzy,\n                FilterMode::Global,\n                &context,\n                \"\",\n                OptFilters {\n                    ..Default::default()\n                },\n            )\n            .await\n            .unwrap();\n        let duration = start.elapsed();\n\n        assert!(duration < Duration::from_secs(15));\n    }\n}\n\npub struct QueryTokenizer<'a> {\n    query: &'a str,\n    last_pos: usize,\n}\n\npub enum QueryToken<'a> {\n    Match(&'a str, bool),\n    MatchStart(&'a str, bool),\n    MatchEnd(&'a str, bool),\n    MatchFull(&'a str, bool),\n    Or,\n    Regex(&'a str),\n}\n\nimpl<'a> QueryToken<'a> {\n    pub fn has_uppercase(&self) -> bool {\n        match self {\n            Self::Match(term, _)\n            | Self::MatchStart(term, _)\n            | Self::MatchEnd(term, _)\n            | Self::MatchFull(term, _) => term.contains(char::is_uppercase),\n            _ => false,\n        }\n    }\n\n    pub fn is_inverse(&self) -> bool {\n        match self {\n            Self::Match(_, inv)\n            | Self::MatchStart(_, inv)\n            | Self::MatchEnd(_, inv)\n            | Self::MatchFull(_, inv) => *inv,\n            _ => false,\n        }\n    }\n}\n\nimpl<'a> QueryTokenizer<'a> {\n    pub fn new(query: &'a str) -> Self {\n        Self { query, last_pos: 0 }\n    }\n}\n\nimpl<'a> Iterator for QueryTokenizer<'a> {\n    type Item = QueryToken<'a>;\n    fn next(&mut self) -> Option<Self::Item> {\n        let remaining = &self.query[self.last_pos..];\n        if remaining.is_empty() {\n            return None;\n        }\n\n        if let Some(remaining) = remaining.strip_prefix(\"r/\") {\n            let (regex, next_pos) = if let Some(end) = remaining.find(\"/ \") {\n                (&remaining[..end], self.last_pos + 2 + end + 2)\n            } else if let Some(remaining) = remaining.strip_suffix('/') {\n                (remaining, self.query.len())\n            } else {\n                (remaining, self.query.len())\n            };\n            self.last_pos = next_pos;\n            Some(QueryToken::Regex(regex))\n        } else {\n            let (mut part, next_pos) = if let Some(sp) = remaining.find(' ') {\n                (&remaining[..sp], self.last_pos + sp + 1)\n            } else {\n                (remaining, self.query.len())\n            };\n            self.last_pos = next_pos;\n\n            if part == \"|\" {\n                return Some(QueryToken::Or);\n            }\n\n            let mut is_inverse = false;\n            if let Some(s) = part.strip_prefix('!') {\n                part = s;\n                is_inverse = true;\n            }\n            let token = if let Some(s) = part.strip_prefix('^') {\n                QueryToken::MatchStart(s, is_inverse)\n            } else if let Some(s) = part.strip_suffix('$') {\n                QueryToken::MatchEnd(s, is_inverse)\n            } else if let Some(s) = part.strip_prefix('\\'') {\n                QueryToken::MatchFull(s, is_inverse)\n            } else {\n                QueryToken::Match(part, is_inverse)\n            };\n            Some(token)\n        }\n    }\n}\n"
  },
  {
    "path": "crates/atuin-client/src/distro.rs",
    "content": "use std::process::Command;\n\n/// Detect the Linux distribution from the system,\n/// using system-specific release files and falling\n/// back to lsb_release.\npub fn detect_linux_distribution() -> String {\n    detect_from_os_release()\n        .or_else(detect_from_debian_version)\n        .or_else(detect_from_centos_release)\n        .or_else(detect_from_redhat_release)\n        .or_else(detect_from_fedora_release)\n        .or_else(detect_from_arch_release)\n        .or_else(detect_from_alpine_release)\n        .or_else(detect_from_suse_release)\n        .or_else(detect_from_lsb_release)\n        .unwrap_or_else(|| \"Unknown\".to_string())\n}\n\nfn detect_from_os_release() -> Option<String> {\n    let content = std::fs::read_to_string(\"/etc/os-release\").ok()?;\n\n    content\n        .lines()\n        .find(|l| l.starts_with(\"PRETTY_NAME=\"))\n        .and_then(|l| l.split_once('=').map(|s| s.1))\n        .map(|s| s.trim_matches('\"').to_string())\n}\n\nfn detect_from_debian_version() -> Option<String> {\n    std::fs::read_to_string(\"/etc/debian_version\")\n        .ok()\n        .map(|v| format!(\"Debian {}\", v.trim()))\n}\n\nfn detect_from_centos_release() -> Option<String> {\n    std::fs::read_to_string(\"/etc/centos-release\")\n        .ok()\n        .map(|v| v.trim().to_string())\n}\n\nfn detect_from_redhat_release() -> Option<String> {\n    std::fs::read_to_string(\"/etc/redhat-release\")\n        .ok()\n        .map(|v| v.trim().to_string())\n}\n\nfn detect_from_fedora_release() -> Option<String> {\n    std::fs::read_to_string(\"/etc/fedora-release\")\n        .ok()\n        .map(|v| v.trim().to_string())\n}\n\nfn detect_from_arch_release() -> Option<String> {\n    std::fs::read_to_string(\"/etc/arch-release\")\n        .ok()\n        .filter(|v| !v.trim().is_empty())\n        .map(|_| \"Arch Linux\".to_string())\n}\n\nfn detect_from_alpine_release() -> Option<String> {\n    std::fs::read_to_string(\"/etc/alpine-release\")\n        .ok()\n        .map(|v| format!(\"Alpine {}\", v.trim()))\n}\n\nfn detect_from_suse_release() -> Option<String> {\n    std::fs::read_to_string(\"/etc/SuSE-release\")\n        .ok()\n        .and_then(|content| content.lines().next().map(|l| l.trim().to_string()))\n}\n\nfn detect_from_lsb_release() -> Option<String> {\n    let output = Command::new(\"lsb_release\").arg(\"-a\").output().ok()?;\n\n    if !output.status.success() {\n        return None;\n    }\n\n    let output = String::from_utf8(output.stdout).ok()?;\n    linux_distro_from_lsb_release(&output)\n}\n\nfn linux_distro_from_lsb_release(output: &str) -> Option<String> {\n    output\n        .lines()\n        .find(|line| line.starts_with(\"Description:\"))\n        .and_then(|line| line.split_once(':').map(|s| s.1))\n        .map(|s| s.trim().to_string())\n}\n"
  },
  {
    "path": "crates/atuin-client/src/encryption.rs",
    "content": "// The general idea is that we NEVER send cleartext history to the server\n// This way the odds of anything private ending up where it should not are\n// very low\n// The server authenticates via the usual username and password. This has\n// nothing to do with the encryption, and is purely authentication! The client\n// generates its own secret key, and encrypts all shell history with libsodium's\n// secretbox. The data is then sent to the server, where it is stored. All\n// clients must share the secret in order to be able to sync, as it is needed\n// to decrypt\n\nuse std::{io::prelude::*, path::PathBuf};\n\nuse base64::prelude::{BASE64_STANDARD, Engine};\npub use crypto_secretbox::Key;\nuse crypto_secretbox::{\n    AeadCore, AeadInPlace, KeyInit, XSalsa20Poly1305,\n    aead::{Nonce, OsRng},\n};\nuse eyre::{Context, Result, bail, ensure, eyre};\nuse fs_err as fs;\nuse rmp::{Marker, decode::Bytes};\nuse serde::{Deserialize, Serialize};\nuse time::{OffsetDateTime, format_description::well_known::Rfc3339, macros::format_description};\n\nuse crate::{history::History, settings::Settings};\n\n#[derive(Debug, Serialize, Deserialize)]\npub struct EncryptedHistory {\n    pub ciphertext: Vec<u8>,\n    pub nonce: Nonce<XSalsa20Poly1305>,\n}\n\npub fn generate_encoded_key() -> Result<(Key, String)> {\n    let key = XSalsa20Poly1305::generate_key(&mut OsRng);\n    let encoded = encode_key(&key)?;\n\n    Ok((key, encoded))\n}\n\npub fn new_key(settings: &Settings) -> Result<Key> {\n    let path = settings.key_path.as_str();\n    let path = PathBuf::from(path);\n\n    if path.exists() {\n        bail!(\"key already exists! cannot overwrite\");\n    }\n\n    let (key, encoded) = generate_encoded_key()?;\n\n    let mut file = fs::File::create(path)?;\n    file.write_all(encoded.as_bytes())?;\n\n    Ok(key)\n}\n\n// Loads the secret key, will create + save if it doesn't exist\npub fn load_key(settings: &Settings) -> Result<Key> {\n    let path = settings.key_path.as_str();\n\n    let key = if PathBuf::from(path).exists() {\n        let key = fs_err::read_to_string(path)?;\n        decode_key(key)?\n    } else {\n        new_key(settings)?\n    };\n\n    Ok(key)\n}\n\npub fn encode_key(key: &Key) -> Result<String> {\n    let mut buf = vec![];\n    rmp::encode::write_array_len(&mut buf, key.len() as u32)\n        .wrap_err(\"could not encode key to message pack\")?;\n    for b in key {\n        rmp::encode::write_uint(&mut buf, *b as u64)\n            .wrap_err(\"could not encode key to message pack\")?;\n    }\n    let buf = BASE64_STANDARD.encode(buf);\n\n    Ok(buf)\n}\n\npub fn decode_key(key: String) -> Result<Key> {\n    use rmp::decode;\n\n    let buf = BASE64_STANDARD\n        .decode(key.trim_end())\n        .wrap_err(\"encryption key is not a valid base64 encoding\")?;\n\n    // old code wrote the key as a fixed length array of 32 bytes\n    // new code writes the key with a length prefix\n    match <[u8; 32]>::try_from(&*buf) {\n        Ok(key) => Ok(key.into()),\n        Err(_) => {\n            let mut bytes = rmp::decode::Bytes::new(&buf);\n\n            match Marker::from_u8(buf[0]) {\n                Marker::Bin8 => {\n                    let len = decode::read_bin_len(&mut bytes).map_err(|err| eyre!(\"{err:?}\"))?;\n                    ensure!(len == 32, \"encryption key is not the correct size\");\n                    let key = <[u8; 32]>::try_from(bytes.remaining_slice())\n                        .context(\"could not decode encryption key\")?;\n                    Ok(key.into())\n                }\n                Marker::Array16 => {\n                    let len = decode::read_array_len(&mut bytes).map_err(|err| eyre!(\"{err:?}\"))?;\n                    ensure!(len == 32, \"encryption key is not the correct size\");\n\n                    let mut key = Key::default();\n                    for i in &mut key {\n                        *i = rmp::decode::read_int(&mut bytes).map_err(|err| eyre!(\"{err:?}\"))?;\n                    }\n                    Ok(key)\n                }\n                _ => bail!(\"could not decode encryption key\"),\n            }\n        }\n    }\n}\n\npub fn encrypt(history: &History, key: &Key) -> Result<EncryptedHistory> {\n    // serialize with msgpack\n    let mut buf = encode(history)?;\n\n    let nonce = XSalsa20Poly1305::generate_nonce(&mut OsRng);\n    XSalsa20Poly1305::new(key)\n        .encrypt_in_place(&nonce, &[], &mut buf)\n        .map_err(|_| eyre!(\"could not encrypt\"))?;\n\n    Ok(EncryptedHistory {\n        ciphertext: buf,\n        nonce,\n    })\n}\n\npub fn decrypt(mut encrypted_history: EncryptedHistory, key: &Key) -> Result<History> {\n    XSalsa20Poly1305::new(key)\n        .decrypt_in_place(\n            &encrypted_history.nonce,\n            &[],\n            &mut encrypted_history.ciphertext,\n        )\n        .map_err(|_| eyre!(\"could not decrypt history\"))?;\n    let plaintext = encrypted_history.ciphertext;\n\n    let history = decode(&plaintext)?;\n\n    Ok(history)\n}\n\nfn format_rfc3339(ts: OffsetDateTime) -> Result<String> {\n    // horrible hack. chrono AutoSI limits to 0, 3, 6, or 9 decimal places for nanoseconds.\n    // time does not have this functionality.\n    static PARTIAL_RFC3339_0: &[time::format_description::FormatItem<'static>] =\n        format_description!(\"[year]-[month]-[day]T[hour]:[minute]:[second]Z\");\n    static PARTIAL_RFC3339_3: &[time::format_description::FormatItem<'static>] =\n        format_description!(\"[year]-[month]-[day]T[hour]:[minute]:[second].[subsecond digits:3]Z\");\n    static PARTIAL_RFC3339_6: &[time::format_description::FormatItem<'static>] =\n        format_description!(\"[year]-[month]-[day]T[hour]:[minute]:[second].[subsecond digits:6]Z\");\n    static PARTIAL_RFC3339_9: &[time::format_description::FormatItem<'static>] =\n        format_description!(\"[year]-[month]-[day]T[hour]:[minute]:[second].[subsecond digits:9]Z\");\n\n    let fmt = match ts.nanosecond() {\n        0 => PARTIAL_RFC3339_0,\n        ns if ns % 1_000_000 == 0 => PARTIAL_RFC3339_3,\n        ns if ns % 1_000 == 0 => PARTIAL_RFC3339_6,\n        _ => PARTIAL_RFC3339_9,\n    };\n\n    Ok(ts.format(fmt)?)\n}\n\nfn encode(h: &History) -> Result<Vec<u8>> {\n    use rmp::encode;\n\n    let mut output = vec![];\n    // INFO: ensure this is updated when adding new fields\n    encode::write_array_len(&mut output, 9)?;\n\n    encode::write_str(&mut output, &h.id.0)?;\n    encode::write_str(&mut output, &(format_rfc3339(h.timestamp)?))?;\n    encode::write_sint(&mut output, h.duration)?;\n    encode::write_sint(&mut output, h.exit)?;\n    encode::write_str(&mut output, &h.command)?;\n    encode::write_str(&mut output, &h.cwd)?;\n    encode::write_str(&mut output, &h.session)?;\n    encode::write_str(&mut output, &h.hostname)?;\n    match h.deleted_at {\n        Some(d) => encode::write_str(&mut output, &format_rfc3339(d)?)?,\n        None => encode::write_nil(&mut output)?,\n    }\n\n    Ok(output)\n}\n\nfn decode(bytes: &[u8]) -> Result<History> {\n    use rmp::decode::{self, DecodeStringError};\n\n    let mut bytes = Bytes::new(bytes);\n\n    let nfields = decode::read_array_len(&mut bytes).map_err(error_report)?;\n    if nfields < 8 {\n        bail!(\"malformed decrypted history\")\n    }\n    if nfields > 9 {\n        bail!(\"cannot decrypt history from a newer version of atuin\");\n    }\n\n    let bytes = bytes.remaining_slice();\n    let (id, bytes) = decode::read_str_from_slice(bytes).map_err(error_report)?;\n    let (timestamp, bytes) = decode::read_str_from_slice(bytes).map_err(error_report)?;\n\n    let mut bytes = Bytes::new(bytes);\n    let duration = decode::read_int(&mut bytes).map_err(error_report)?;\n    let exit = decode::read_int(&mut bytes).map_err(error_report)?;\n\n    let bytes = bytes.remaining_slice();\n    let (command, bytes) = decode::read_str_from_slice(bytes).map_err(error_report)?;\n    let (cwd, bytes) = decode::read_str_from_slice(bytes).map_err(error_report)?;\n    let (session, bytes) = decode::read_str_from_slice(bytes).map_err(error_report)?;\n    let (hostname, bytes) = decode::read_str_from_slice(bytes).map_err(error_report)?;\n\n    // if we have more fields, try and get the deleted_at\n    let mut deleted_at = None;\n    let mut bytes = bytes;\n    if nfields > 8 {\n        bytes = match decode::read_str_from_slice(bytes) {\n            Ok((d, b)) => {\n                deleted_at = Some(d);\n                b\n            }\n            // we accept null here\n            Err(DecodeStringError::TypeMismatch(Marker::Null)) => {\n                // consume the null marker\n                let mut c = Bytes::new(bytes);\n                decode::read_nil(&mut c).map_err(error_report)?;\n                c.remaining_slice()\n            }\n            Err(err) => return Err(error_report(err)),\n        };\n    }\n\n    if !bytes.is_empty() {\n        bail!(\"trailing bytes in encoded history. malformed\")\n    }\n\n    Ok(History {\n        id: id.to_owned().into(),\n        timestamp: OffsetDateTime::parse(timestamp, &Rfc3339)?,\n        duration,\n        exit,\n        command: command.to_owned(),\n        cwd: cwd.to_owned(),\n        session: session.to_owned(),\n        hostname: hostname.to_owned(),\n        author: History::author_from_hostname(hostname),\n        intent: None,\n        deleted_at: deleted_at\n            .map(|t| OffsetDateTime::parse(t, &Rfc3339))\n            .transpose()?,\n    })\n}\n\nfn error_report<E: std::fmt::Debug>(err: E) -> eyre::Report {\n    eyre!(\"{err:?}\")\n}\n\n#[cfg(test)]\nmod test {\n    use crypto_secretbox::{KeyInit, XSalsa20Poly1305, aead::OsRng};\n    use pretty_assertions::assert_eq;\n    use time::{OffsetDateTime, macros::datetime};\n\n    use crate::history::History;\n\n    use super::{decode, decrypt, encode, encrypt};\n\n    #[test]\n    fn test_encrypt_decrypt() {\n        let key1 = XSalsa20Poly1305::generate_key(&mut OsRng);\n        let key2 = XSalsa20Poly1305::generate_key(&mut OsRng);\n\n        let history = History::from_db()\n            .id(\"1\".into())\n            .timestamp(OffsetDateTime::now_utc())\n            .command(\"ls\".into())\n            .cwd(\"/home/ellie\".into())\n            .exit(0)\n            .duration(1)\n            .session(\"beep boop\".into())\n            .hostname(\"booop\".into())\n            .author(\"booop\".into())\n            .intent(None)\n            .deleted_at(None)\n            .build()\n            .into();\n\n        let e1 = encrypt(&history, &key1).unwrap();\n        let e2 = encrypt(&history, &key2).unwrap();\n\n        assert_ne!(e1.ciphertext, e2.ciphertext);\n        assert_ne!(e1.nonce, e2.nonce);\n\n        // test decryption works\n        // this should pass\n        match decrypt(e1, &key1) {\n            Err(e) => panic!(\"failed to decrypt, got {e}\"),\n            Ok(h) => assert_eq!(h, history),\n        };\n\n        // this should err\n        let _ = decrypt(e2, &key1).expect_err(\"expected an error decrypting with invalid key\");\n    }\n\n    #[test]\n    fn test_decode() {\n        let bytes = [\n            0x99, 0xD9, 32, 54, 54, 100, 49, 54, 99, 98, 101, 101, 55, 99, 100, 52, 55, 53, 51, 56,\n            101, 53, 99, 53, 98, 56, 98, 52, 52, 101, 57, 48, 48, 54, 101, 187, 50, 48, 50, 51, 45,\n            48, 53, 45, 50, 56, 84, 49, 56, 58, 51, 53, 58, 52, 48, 46, 54, 51, 51, 56, 55, 50, 90,\n            206, 2, 238, 210, 240, 0, 170, 103, 105, 116, 32, 115, 116, 97, 116, 117, 115, 217, 42,\n            47, 85, 115, 101, 114, 115, 47, 99, 111, 110, 114, 97, 100, 46, 108, 117, 100, 103, 97,\n            116, 101, 47, 68, 111, 99, 117, 109, 101, 110, 116, 115, 47, 99, 111, 100, 101, 47, 97,\n            116, 117, 105, 110, 217, 32, 98, 57, 55, 100, 57, 97, 51, 48, 54, 102, 50, 55, 52, 52,\n            55, 51, 97, 50, 48, 51, 100, 50, 101, 98, 97, 52, 49, 102, 57, 52, 53, 55, 187, 102,\n            118, 102, 103, 57, 51, 54, 99, 48, 107, 112, 102, 58, 99, 111, 110, 114, 97, 100, 46,\n            108, 117, 100, 103, 97, 116, 101, 192,\n        ];\n        let history = History {\n            id: \"66d16cbee7cd47538e5c5b8b44e9006e\".to_owned().into(),\n            timestamp: datetime!(2023-05-28 18:35:40.633872 +00:00),\n            duration: 49206000,\n            exit: 0,\n            command: \"git status\".to_owned(),\n            cwd: \"/Users/conrad.ludgate/Documents/code/atuin\".to_owned(),\n            session: \"b97d9a306f274473a203d2eba41f9457\".to_owned(),\n            hostname: \"fvfg936c0kpf:conrad.ludgate\".to_owned(),\n            author: \"conrad.ludgate\".to_owned(),\n            intent: None,\n            deleted_at: None,\n        };\n\n        let h = decode(&bytes).unwrap();\n        assert_eq!(history, h);\n\n        let b = encode(&h).unwrap();\n        assert_eq!(&bytes, &*b);\n    }\n\n    #[test]\n    fn test_decode_deleted() {\n        let history = History {\n            id: \"66d16cbee7cd47538e5c5b8b44e9006e\".to_owned().into(),\n            timestamp: datetime!(2023-05-28 18:35:40.633872 +00:00),\n            duration: 49206000,\n            exit: 0,\n            command: \"git status\".to_owned(),\n            cwd: \"/Users/conrad.ludgate/Documents/code/atuin\".to_owned(),\n            session: \"b97d9a306f274473a203d2eba41f9457\".to_owned(),\n            hostname: \"fvfg936c0kpf:conrad.ludgate\".to_owned(),\n            author: \"conrad.ludgate\".to_owned(),\n            intent: None,\n            deleted_at: Some(datetime!(2023-05-28 18:35:40.633872 +00:00)),\n        };\n\n        let b = encode(&history).unwrap();\n        let h = decode(&b).unwrap();\n        assert_eq!(history, h);\n    }\n\n    #[test]\n    fn test_decode_old() {\n        let bytes = [\n            0x98, 0xD9, 32, 54, 54, 100, 49, 54, 99, 98, 101, 101, 55, 99, 100, 52, 55, 53, 51, 56,\n            101, 53, 99, 53, 98, 56, 98, 52, 52, 101, 57, 48, 48, 54, 101, 187, 50, 48, 50, 51, 45,\n            48, 53, 45, 50, 56, 84, 49, 56, 58, 51, 53, 58, 52, 48, 46, 54, 51, 51, 56, 55, 50, 90,\n            206, 2, 238, 210, 240, 0, 170, 103, 105, 116, 32, 115, 116, 97, 116, 117, 115, 217, 42,\n            47, 85, 115, 101, 114, 115, 47, 99, 111, 110, 114, 97, 100, 46, 108, 117, 100, 103, 97,\n            116, 101, 47, 68, 111, 99, 117, 109, 101, 110, 116, 115, 47, 99, 111, 100, 101, 47, 97,\n            116, 117, 105, 110, 217, 32, 98, 57, 55, 100, 57, 97, 51, 48, 54, 102, 50, 55, 52, 52,\n            55, 51, 97, 50, 48, 51, 100, 50, 101, 98, 97, 52, 49, 102, 57, 52, 53, 55, 187, 102,\n            118, 102, 103, 57, 51, 54, 99, 48, 107, 112, 102, 58, 99, 111, 110, 114, 97, 100, 46,\n            108, 117, 100, 103, 97, 116, 101,\n        ];\n        let history = History {\n            id: \"66d16cbee7cd47538e5c5b8b44e9006e\".to_owned().into(),\n            timestamp: datetime!(2023-05-28 18:35:40.633872 +00:00),\n            duration: 49206000,\n            exit: 0,\n            command: \"git status\".to_owned(),\n            cwd: \"/Users/conrad.ludgate/Documents/code/atuin\".to_owned(),\n            session: \"b97d9a306f274473a203d2eba41f9457\".to_owned(),\n            hostname: \"fvfg936c0kpf:conrad.ludgate\".to_owned(),\n            author: \"conrad.ludgate\".to_owned(),\n            intent: None,\n            deleted_at: None,\n        };\n\n        let h = decode(&bytes).unwrap();\n        assert_eq!(history, h);\n    }\n\n    #[test]\n    fn key_encodings() {\n        use super::{Key, decode_key, encode_key};\n\n        // a history of our key encodings.\n        // v11.0.0 xCAbWypb0msJ2Kq+8j4GVEWUlDX7deKnrTRSIopuqXxc5Q==\n        // v12.0.0 xCAbWypb0msJ2Kq+8j4GVEWUlDX7deKnrTRSIopuqXxc5Q==\n        // v13.0.0 xCAbWypb0msJ2Kq+8j4GVEWUlDX7deKnrTRSIopuqXxc5Q==\n        // v13.0.1 xCAbWypb0msJ2Kq+8j4GVEWUlDX7deKnrTRSIopuqXxc5Q==\n        // v14.0.0 xCAbWypb0msJ2Kq+8j4GVEWUlDX7deKnrTRSIopuqXxc5Q==\n        // v14.0.1 xCAbWypb0msJ2Kq+8j4GVEWUlDX7deKnrTRSIopuqXxc5Q==\n        // c7d89c1 3AAgG1sqW8zSawnM2MyqzL7M8j4GVEXMlMyUNcz7dczizKfMrTRSIsyKbsypfFzM5Q== (https://github.com/ellie/atuin/pull/805)\n        // b53ca35 3AAgG1sqW8zSawnM2MyqzL7M8j4GVEXMlMyUNcz7dczizKfMrTRSIsyKbsypfFzM5Q== (https://github.com/ellie/atuin/pull/974)\n        // v15.0.0 3AAgG1sqW8zSawnM2MyqzL7M8j4GVEXMlMyUNcz7dczizKfMrTRSIsyKbsypfFzM5Q==\n        // b8b57c8 xCAbWypb0msJ2Kq+8j4GVEWUlDX7deKnrTRSIopuqXxc5Q==                     (https://github.com/ellie/atuin/pull/1057)\n        // 8c94d79 3AAgG1sqW8zSawnM2MyqzL7M8j4GVEXMlMyUNcz7dczizKfMrTRSIsyKbsypfFzM5Q== (https://github.com/ellie/atuin/pull/1089)\n\n        let key = Key::from([\n            27, 91, 42, 91, 210, 107, 9, 216, 170, 190, 242, 62, 6, 84, 69, 148, 148, 53, 251, 117,\n            226, 167, 173, 52, 82, 34, 138, 110, 169, 124, 92, 229,\n        ]);\n\n        assert_eq!(\n            encode_key(&key).unwrap(),\n            \"3AAgG1sqW8zSawnM2MyqzL7M8j4GVEXMlMyUNcz7dczizKfMrTRSIsyKbsypfFzM5Q==\"\n        );\n\n        // key encodings we have to support\n        let valid_encodings = [\n            \"xCAbWypb0msJ2Kq+8j4GVEWUlDX7deKnrTRSIopuqXxc5Q==\",\n            \"3AAgG1sqW8zSawnM2MyqzL7M8j4GVEXMlMyUNcz7dczizKfMrTRSIsyKbsypfFzM5Q==\",\n        ];\n\n        for k in valid_encodings {\n            assert_eq!(decode_key(k.to_owned()).expect(k), key);\n        }\n    }\n}\n"
  },
  {
    "path": "crates/atuin-client/src/history/builder.rs",
    "content": "use typed_builder::TypedBuilder;\n\nuse super::History;\n\n/// Builder for a history entry that is imported from shell history.\n///\n/// The only two required fields are `timestamp` and `command`.\n#[derive(Debug, Clone, TypedBuilder)]\npub struct HistoryImported {\n    timestamp: time::OffsetDateTime,\n    #[builder(setter(into))]\n    command: String,\n    #[builder(default = \"unknown\".into(), setter(into))]\n    cwd: String,\n    #[builder(default = -1)]\n    exit: i64,\n    #[builder(default = -1)]\n    duration: i64,\n    #[builder(default, setter(strip_option, into))]\n    session: Option<String>,\n    #[builder(default, setter(strip_option, into))]\n    hostname: Option<String>,\n    #[builder(default, setter(strip_option, into))]\n    author: Option<String>,\n    #[builder(default, setter(strip_option, into))]\n    intent: Option<String>,\n}\n\nimpl From<HistoryImported> for History {\n    fn from(imported: HistoryImported) -> Self {\n        History::new(\n            imported.timestamp,\n            imported.command,\n            imported.cwd,\n            imported.exit,\n            imported.duration,\n            imported.session,\n            imported.hostname,\n            imported.author,\n            imported.intent,\n            None,\n        )\n    }\n}\n\n/// Builder for a history entry that is captured via hook.\n///\n/// This builder is used only at the `start` step of the hook,\n/// so it doesn't have any fields which are known only after\n/// the command is finished, such as `exit` or `duration`.\n#[derive(Debug, Clone, TypedBuilder)]\npub struct HistoryCaptured {\n    timestamp: time::OffsetDateTime,\n    #[builder(setter(into))]\n    command: String,\n    #[builder(setter(into))]\n    cwd: String,\n    #[builder(default, setter(strip_option, into))]\n    author: Option<String>,\n    #[builder(default, setter(strip_option, into))]\n    intent: Option<String>,\n}\n\nimpl From<HistoryCaptured> for History {\n    fn from(captured: HistoryCaptured) -> Self {\n        History::new(\n            captured.timestamp,\n            captured.command,\n            captured.cwd,\n            -1,\n            -1,\n            None,\n            None,\n            captured.author,\n            captured.intent,\n            None,\n        )\n    }\n}\n\n/// Builder for a history entry that is loaded from the database.\n///\n/// All fields are required, as they are all present in the database.\n#[derive(Debug, Clone, TypedBuilder)]\npub struct HistoryFromDb {\n    id: String,\n    timestamp: time::OffsetDateTime,\n    command: String,\n    cwd: String,\n    exit: i64,\n    duration: i64,\n    session: String,\n    hostname: String,\n    author: String,\n    intent: Option<String>,\n    deleted_at: Option<time::OffsetDateTime>,\n}\n\nimpl From<HistoryFromDb> for History {\n    fn from(from_db: HistoryFromDb) -> Self {\n        History {\n            id: from_db.id.into(),\n            timestamp: from_db.timestamp,\n            exit: from_db.exit,\n            command: from_db.command,\n            cwd: from_db.cwd,\n            duration: from_db.duration,\n            session: from_db.session,\n            hostname: from_db.hostname,\n            author: from_db.author,\n            intent: from_db.intent,\n            deleted_at: from_db.deleted_at,\n        }\n    }\n}\n\n/// Builder for a history entry that is captured via hook and sent to the daemon\n///\n/// This builder is similar to Capture, but we just require more information up front.\n/// For the old setup, we could just rely on History::new to read some of the missing\n/// data. This is no longer the case.\n#[derive(Debug, Clone, TypedBuilder)]\npub struct HistoryDaemonCapture {\n    timestamp: time::OffsetDateTime,\n    #[builder(setter(into))]\n    command: String,\n    #[builder(setter(into))]\n    cwd: String,\n    #[builder(setter(into))]\n    session: String,\n    #[builder(setter(into))]\n    hostname: String,\n    #[builder(default, setter(strip_option, into))]\n    author: Option<String>,\n    #[builder(default, setter(strip_option, into))]\n    intent: Option<String>,\n}\n\nimpl From<HistoryDaemonCapture> for History {\n    fn from(captured: HistoryDaemonCapture) -> Self {\n        History::new(\n            captured.timestamp,\n            captured.command,\n            captured.cwd,\n            -1,\n            -1,\n            Some(captured.session),\n            Some(captured.hostname),\n            captured.author,\n            captured.intent,\n            None,\n        )\n    }\n}\n"
  },
  {
    "path": "crates/atuin-client/src/history/store.rs",
    "content": "use std::{collections::HashSet, fmt::Write, time::Duration};\n\nuse eyre::{Result, bail, eyre};\nuse indicatif::{ProgressBar, ProgressState, ProgressStyle};\nuse rmp::decode::Bytes;\n\nuse crate::{\n    database::{Database, current_context},\n    record::{encryption::PASETO_V4, sqlite_store::SqliteStore, store::Store},\n};\nuse atuin_common::record::{DecryptedData, Host, HostId, Record, RecordId, RecordIdx};\n\nuse super::{HISTORY_TAG, HISTORY_VERSION, HISTORY_VERSION_V0, History, HistoryId};\n\n#[derive(Debug, Clone)]\npub struct HistoryStore {\n    pub store: SqliteStore,\n    pub host_id: HostId,\n    pub encryption_key: [u8; 32],\n}\n\n#[derive(Debug, Eq, PartialEq, Clone)]\npub enum HistoryRecord {\n    Create(History),   // Create a history record\n    Delete(HistoryId), // Delete a history record, identified by ID\n}\n\nimpl HistoryRecord {\n    /// Serialize a history record, returning DecryptedData\n    /// The record will be of a certain type\n    /// We map those like so:\n    ///\n    /// HistoryRecord::Create -> 0\n    /// HistoryRecord::Delete-> 1\n    ///\n    /// This numeric identifier is then written as the first byte to the buffer. For history, we\n    /// append the serialized history right afterwards, to avoid having to handle serialization\n    /// twice.\n    ///\n    /// Deletion simply refers to the history by ID\n    pub fn serialize(&self) -> Result<DecryptedData> {\n        // probably don't actually need to use rmp here, but if we ever need to extend it, it's a\n        // nice wrapper around raw byte stuff\n        use rmp::encode;\n\n        let mut output = vec![];\n\n        match self {\n            HistoryRecord::Create(history) => {\n                // 0 -> a history create\n                encode::write_u8(&mut output, 0)?;\n\n                let bytes = history.serialize()?;\n\n                encode::write_bin(&mut output, &bytes.0)?;\n            }\n            HistoryRecord::Delete(id) => {\n                // 1 -> a history delete\n                encode::write_u8(&mut output, 1)?;\n                encode::write_str(&mut output, id.0.as_str())?;\n            }\n        };\n\n        Ok(DecryptedData(output))\n    }\n\n    pub fn deserialize(bytes: &DecryptedData, version: &str) -> Result<Self> {\n        use rmp::decode;\n\n        fn error_report<E: std::fmt::Debug>(err: E) -> eyre::Report {\n            eyre!(\"{err:?}\")\n        }\n\n        let mut bytes = Bytes::new(&bytes.0);\n\n        let record_type = decode::read_u8(&mut bytes).map_err(error_report)?;\n\n        match record_type {\n            // 0 -> HistoryRecord::Create\n            0 => {\n                // not super useful to us atm, but perhaps in the future\n                // written by write_bin above\n                let _ = decode::read_bin_len(&mut bytes).map_err(error_report)?;\n\n                let record = History::deserialize(bytes.remaining_slice(), version)?;\n\n                Ok(HistoryRecord::Create(record))\n            }\n\n            // 1 -> HistoryRecord::Delete\n            1 => {\n                let bytes = bytes.remaining_slice();\n                let (id, bytes) = decode::read_str_from_slice(bytes).map_err(error_report)?;\n\n                if !bytes.is_empty() {\n                    bail!(\n                        \"trailing bytes decoding HistoryRecord::Delete - malformed? got {bytes:?}\"\n                    );\n                }\n\n                Ok(HistoryRecord::Delete(id.to_string().into()))\n            }\n\n            n => {\n                bail!(\"unknown HistoryRecord type {n}\")\n            }\n        }\n    }\n}\n\nimpl HistoryStore {\n    pub fn new(store: SqliteStore, host_id: HostId, encryption_key: [u8; 32]) -> Self {\n        HistoryStore {\n            store,\n            host_id,\n            encryption_key,\n        }\n    }\n\n    async fn push_record(&self, record: HistoryRecord) -> Result<(RecordId, RecordIdx)> {\n        let bytes = record.serialize()?;\n        let idx = self\n            .store\n            .last(self.host_id, HISTORY_TAG)\n            .await?\n            .map_or(0, |p| p.idx + 1);\n\n        let record = Record::builder()\n            .host(Host::new(self.host_id))\n            .version(HISTORY_VERSION.to_string())\n            .tag(HISTORY_TAG.to_string())\n            .idx(idx)\n            .data(bytes)\n            .build();\n\n        let id = record.id;\n\n        self.store\n            .push(&record.encrypt::<PASETO_V4>(&self.encryption_key))\n            .await?;\n\n        Ok((id, idx))\n    }\n\n    async fn push_batch(&self, records: impl Iterator<Item = HistoryRecord>) -> Result<()> {\n        let mut ret = Vec::new();\n\n        let idx = self\n            .store\n            .last(self.host_id, HISTORY_TAG)\n            .await?\n            .map_or(0, |p| p.idx + 1);\n\n        // Could probably _also_ do this as an iterator, but let's see how this is for now.\n        // optimizing for minimal sqlite transactions, this code can be optimised later\n        for (n, record) in records.enumerate() {\n            let bytes = record.serialize()?;\n\n            let record = Record::builder()\n                .host(Host::new(self.host_id))\n                .version(HISTORY_VERSION.to_string())\n                .tag(HISTORY_TAG.to_string())\n                .idx(idx + n as u64)\n                .data(bytes)\n                .build();\n\n            let record = record.encrypt::<PASETO_V4>(&self.encryption_key);\n\n            ret.push(record);\n        }\n\n        self.store.push_batch(ret.iter()).await?;\n\n        Ok(())\n    }\n\n    pub async fn delete(&self, id: HistoryId) -> Result<(RecordId, RecordIdx)> {\n        let record = HistoryRecord::Delete(id);\n\n        self.push_record(record).await\n    }\n\n    pub async fn push(&self, history: History) -> Result<(RecordId, RecordIdx)> {\n        // TODO(ellie): move the history store to its own file\n        // it's tiny rn so fine as is\n        let record = HistoryRecord::Create(history);\n\n        self.push_record(record).await\n    }\n\n    pub async fn history(&self) -> Result<Vec<HistoryRecord>> {\n        // Atm this loads all history into memory\n        // Not ideal as that is potentially quite a lot, although history will be small.\n        let records = self.store.all_tagged(HISTORY_TAG).await?;\n        let mut ret = Vec::with_capacity(records.len());\n\n        for record in records.into_iter() {\n            let hist = match record.version.as_str() {\n                HISTORY_VERSION_V0 | HISTORY_VERSION => {\n                    let version = record.version.clone();\n                    let decrypted = record.decrypt::<PASETO_V4>(&self.encryption_key)?;\n\n                    HistoryRecord::deserialize(&decrypted.data, version.as_str())\n                }\n                version => bail!(\"unknown history version {version:?}\"),\n            }?;\n\n            ret.push(hist);\n        }\n\n        Ok(ret)\n    }\n\n    pub async fn build(&self, database: &dyn Database) -> Result<()> {\n        // I'd like to change how we rebuild and not couple this with the database, but need to\n        // consider the structure more deeply. This will be easy to change.\n\n        // TODO(ellie): page or iterate this\n        let history = self.history().await?;\n\n        // In theory we could flatten this here\n        // The current issue is that the database may have history in it already, from the old sync\n        // This didn't actually delete old history\n        // If we're sure we have a DB only maintained by the new store, we can flatten\n        // create/delete before we even get to sqlite\n        let mut creates = Vec::new();\n        let mut deletes = Vec::new();\n\n        for i in history {\n            match i {\n                HistoryRecord::Create(h) => {\n                    creates.push(h);\n                }\n                HistoryRecord::Delete(id) => {\n                    deletes.push(id);\n                }\n            }\n        }\n\n        database.save_bulk(&creates).await?;\n        database.delete_rows(&deletes).await?;\n\n        Ok(())\n    }\n\n    pub async fn incremental_build(&self, database: &dyn Database, ids: &[RecordId]) -> Result<()> {\n        for id in ids {\n            let record = self.store.get(*id).await;\n\n            let record = match record {\n                Ok(record) => record,\n                _ => {\n                    continue;\n                }\n            };\n\n            if record.tag != HISTORY_TAG {\n                continue;\n            }\n\n            let version = record.version.clone();\n            let decrypted = record.decrypt::<PASETO_V4>(&self.encryption_key)?;\n            let record = match version.as_str() {\n                HISTORY_VERSION_V0 | HISTORY_VERSION => {\n                    HistoryRecord::deserialize(&decrypted.data, version.as_str())?\n                }\n                version => bail!(\"unknown history version {version:?}\"),\n            };\n\n            match record {\n                HistoryRecord::Create(h) => {\n                    // TODO: benchmark CPU time/memory tradeoff of batch commit vs one at a time\n                    database.save(&h).await?;\n                }\n                HistoryRecord::Delete(id) => {\n                    database.delete_rows(&[id]).await?;\n                }\n            }\n        }\n\n        Ok(())\n    }\n\n    /// Get a list of history IDs that exist in the store\n    /// Note: This currently involves loading all history into memory. This is not going to be a\n    /// large amount in absolute terms, but do not all it in a hot loop.\n    pub async fn history_ids(&self) -> Result<HashSet<HistoryId>> {\n        let history = self.history().await?;\n\n        let ret = HashSet::from_iter(history.iter().map(|h| match h {\n            HistoryRecord::Create(h) => h.id.clone(),\n            HistoryRecord::Delete(id) => id.clone(),\n        }));\n\n        Ok(ret)\n    }\n\n    pub async fn init_store(&self, db: &impl Database) -> Result<()> {\n        let pb = ProgressBar::new_spinner();\n        pb.set_style(\n            ProgressStyle::with_template(\"{spinner:.blue} {msg}\")\n                .unwrap()\n                .with_key(\"eta\", |state: &ProgressState, w: &mut dyn Write| {\n                    write!(w, \"{:.1}s\", state.eta().as_secs_f64()).unwrap()\n                })\n                .progress_chars(\"#>-\"),\n        );\n        pb.enable_steady_tick(Duration::from_millis(500));\n\n        pb.set_message(\"Fetching history from old database\");\n\n        let context = current_context().await?;\n        let history = db.list(&[], &context, None, false, true).await?;\n\n        pb.set_message(\"Fetching history already in store\");\n        let store_ids = self.history_ids().await?;\n\n        pb.set_message(\"Converting old history to new store\");\n        let mut records = Vec::new();\n\n        for i in history {\n            debug!(\"loaded {}\", i.id);\n\n            if store_ids.contains(&i.id) {\n                debug!(\"skipping {} - already exists\", i.id);\n                continue;\n            }\n\n            if i.deleted_at.is_some() {\n                records.push(HistoryRecord::Delete(i.id));\n            } else {\n                records.push(HistoryRecord::Create(i));\n            }\n        }\n\n        pb.set_message(\"Writing to db\");\n\n        if !records.is_empty() {\n            self.push_batch(records.into_iter()).await?;\n        }\n\n        pb.finish_with_message(\"Import complete\");\n\n        Ok(())\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use atuin_common::record::DecryptedData;\n    use time::macros::datetime;\n\n    use crate::history::{HISTORY_VERSION, store::HistoryRecord};\n\n    use super::History;\n\n    #[test]\n    fn test_serialize_deserialize_create() {\n        let bytes = [\n            204, 0, 196, 147, 205, 0, 1, 154, 217, 32, 48, 49, 56, 99, 100, 52, 102, 101, 56, 49,\n            55, 53, 55, 99, 100, 50, 97, 101, 101, 54, 53, 99, 100, 55, 56, 54, 49, 102, 57, 99,\n            56, 49, 207, 23, 166, 251, 212, 181, 82, 0, 0, 100, 0, 162, 108, 115, 217, 41, 47, 85,\n            115, 101, 114, 115, 47, 101, 108, 108, 105, 101, 47, 115, 114, 99, 47, 103, 105, 116,\n            104, 117, 98, 46, 99, 111, 109, 47, 97, 116, 117, 105, 110, 115, 104, 47, 97, 116, 117,\n            105, 110, 217, 32, 48, 49, 56, 99, 100, 52, 102, 101, 97, 100, 56, 57, 55, 53, 57, 55,\n            56, 53, 50, 53, 50, 55, 97, 51, 49, 99, 57, 57, 56, 48, 53, 57, 170, 98, 111, 111, 112,\n            58, 101, 108, 108, 105, 101, 192, 165, 101, 108, 108, 105, 101,\n        ];\n\n        let history = History {\n            id: \"018cd4fe81757cd2aee65cd7861f9c81\".to_owned().into(),\n            timestamp: datetime!(2024-01-04 00:00:00.000000 +00:00),\n            duration: 100,\n            exit: 0,\n            command: \"ls\".to_owned(),\n            cwd: \"/Users/ellie/src/github.com/atuinsh/atuin\".to_owned(),\n            session: \"018cd4fead897597852527a31c998059\".to_owned(),\n            hostname: \"boop:ellie\".to_owned(),\n            author: \"ellie\".to_owned(),\n            intent: None,\n            deleted_at: None,\n        };\n\n        let record = HistoryRecord::Create(history);\n\n        let serialized = record.serialize().expect(\"failed to serialize history\");\n        assert_eq!(serialized.0, bytes);\n\n        let deserialized = HistoryRecord::deserialize(&serialized, HISTORY_VERSION)\n            .expect(\"failed to deserialize HistoryRecord\");\n        assert_eq!(deserialized, record);\n\n        // check the snapshot too\n        let deserialized =\n            HistoryRecord::deserialize(&DecryptedData(Vec::from(bytes)), HISTORY_VERSION)\n                .expect(\"failed to deserialize HistoryRecord\");\n        assert_eq!(deserialized, record);\n    }\n\n    #[test]\n    fn test_serialize_deserialize_delete() {\n        let bytes = [\n            204, 1, 217, 32, 48, 49, 56, 99, 100, 52, 102, 101, 56, 49, 55, 53, 55, 99, 100, 50,\n            97, 101, 101, 54, 53, 99, 100, 55, 56, 54, 49, 102, 57, 99, 56, 49,\n        ];\n        let record = HistoryRecord::Delete(\"018cd4fe81757cd2aee65cd7861f9c81\".to_string().into());\n\n        let serialized = record.serialize().expect(\"failed to serialize history\");\n        assert_eq!(serialized.0, bytes);\n\n        let deserialized = HistoryRecord::deserialize(&serialized, HISTORY_VERSION)\n            .expect(\"failed to deserialize HistoryRecord\");\n        assert_eq!(deserialized, record);\n\n        let deserialized =\n            HistoryRecord::deserialize(&DecryptedData(Vec::from(bytes)), HISTORY_VERSION)\n                .expect(\"failed to deserialize HistoryRecord\");\n        assert_eq!(deserialized, record);\n    }\n}\n"
  },
  {
    "path": "crates/atuin-client/src/history.rs",
    "content": "use core::fmt::Formatter;\nuse rmp::decode::DecodeStringError;\nuse rmp::decode::ValueReadError;\nuse rmp::{Marker, decode::Bytes};\nuse std::env;\nuse std::fmt::Display;\n\nuse atuin_common::record::DecryptedData;\nuse atuin_common::utils::uuid_v7;\n\nuse eyre::{Result, bail, eyre};\n\nuse crate::secrets::SECRET_PATTERNS_RE;\nuse crate::settings::Settings;\nuse crate::utils::get_host_user;\nuse time::OffsetDateTime;\n\nmod builder;\npub mod store;\n\npub(crate) const HISTORY_VERSION_V0: &str = \"v0\";\npub(crate) const HISTORY_VERSION_V1: &str = \"v1\";\nconst HISTORY_RECORD_VERSION_V0: u16 = 0;\nconst HISTORY_RECORD_VERSION_V1: u16 = 1;\npub(crate) const HISTORY_VERSION: &str = HISTORY_VERSION_V1;\npub const HISTORY_TAG: &str = \"history\";\nconst HISTORY_AUTHOR_ENV: &str = \"ATUIN_HISTORY_AUTHOR\";\nconst HISTORY_INTENT_ENV: &str = \"ATUIN_HISTORY_INTENT\";\n\n#[derive(Clone, Debug, Eq, PartialEq, Hash)]\npub struct HistoryId(pub String);\n\nimpl Display for HistoryId {\n    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {\n        write!(f, \"{}\", self.0)\n    }\n}\n\nimpl From<String> for HistoryId {\n    fn from(s: String) -> Self {\n        Self(s)\n    }\n}\n\n/// Client-side history entry.\n///\n/// Client stores data unencrypted, and only encrypts it before sending to the server.\n///\n/// To create a new history entry, use one of the builders:\n/// - [`History::import()`] to import an entry from the shell history file\n/// - [`History::capture()`] to capture an entry via hook\n/// - [`History::from_db()`] to create an instance from the database entry\n//\n// ## Implementation Notes\n//\n// New fields must be added to `History::{serialize,deserialize}` in a backwards\n// compatible way (sensible defaults and careful `nfields` handling).\n#[derive(Debug, Clone, PartialEq, Eq, sqlx::FromRow)]\npub struct History {\n    /// A client-generated ID, used to identify the entry when syncing.\n    ///\n    /// Stored as `client_id` in the database.\n    pub id: HistoryId,\n    /// When the command was run.\n    pub timestamp: OffsetDateTime,\n    /// How long the command took to run.\n    pub duration: i64,\n    /// The exit code of the command.\n    pub exit: i64,\n    /// The command that was run.\n    pub command: String,\n    /// The current working directory when the command was run.\n    pub cwd: String,\n    /// The session ID, associated with a terminal session.\n    pub session: String,\n    /// The hostname of the machine the command was run on.\n    pub hostname: String,\n    /// Who wrote this command (human user or automation/agent identity).\n    pub author: String,\n    /// Optional rationale for why the command was executed.\n    pub intent: Option<String>,\n    /// Timestamp, which is set when the entry is deleted, allowing a soft delete.\n    pub deleted_at: Option<OffsetDateTime>,\n}\n\n#[derive(Debug, Clone, PartialEq, Eq, sqlx::FromRow)]\npub struct HistoryStats {\n    /// The command that was ran after this one in the session\n    pub next: Option<History>,\n    ///\n    /// The command that was ran before this one in the session\n    pub previous: Option<History>,\n\n    /// How many times has this command been ran?\n    pub total: u64,\n\n    pub average_duration: u64,\n\n    pub exits: Vec<(i64, i64)>,\n\n    pub day_of_week: Vec<(String, i64)>,\n\n    pub duration_over_time: Vec<(String, i64)>,\n}\n\nimpl History {\n    pub(crate) fn author_from_hostname(hostname: &str) -> String {\n        hostname\n            .split_once(':')\n            .map_or_else(|| hostname.to_owned(), |(_, user)| user.to_owned())\n    }\n\n    fn normalize_optional_field(field: Option<String>) -> Option<String> {\n        field.and_then(|value| {\n            let trimmed = value.trim();\n            if trimmed.is_empty() {\n                None\n            } else {\n                Some(trimmed.to_owned())\n            }\n        })\n    }\n\n    #[allow(clippy::too_many_arguments)]\n    fn new(\n        timestamp: OffsetDateTime,\n        command: String,\n        cwd: String,\n        exit: i64,\n        duration: i64,\n        session: Option<String>,\n        hostname: Option<String>,\n        author: Option<String>,\n        intent: Option<String>,\n        deleted_at: Option<OffsetDateTime>,\n    ) -> Self {\n        let session = session\n            .or_else(|| env::var(\"ATUIN_SESSION\").ok())\n            .unwrap_or_else(|| uuid_v7().as_simple().to_string());\n        let hostname = hostname.unwrap_or_else(get_host_user);\n        let author = Self::normalize_optional_field(author)\n            .or_else(|| Self::normalize_optional_field(env::var(HISTORY_AUTHOR_ENV).ok()))\n            .unwrap_or_else(|| Self::author_from_hostname(hostname.as_str()));\n        let intent = Self::normalize_optional_field(intent)\n            .or_else(|| Self::normalize_optional_field(env::var(HISTORY_INTENT_ENV).ok()));\n\n        Self {\n            id: uuid_v7().as_simple().to_string().into(),\n            timestamp,\n            command,\n            cwd,\n            exit,\n            duration,\n            session,\n            hostname,\n            author,\n            intent,\n            deleted_at,\n        }\n    }\n\n    pub fn serialize(&self) -> Result<DecryptedData> {\n        // This is pretty much the same as what we used for the old history, with one difference -\n        // it uses integers for timestamps rather than a string format.\n\n        use rmp::encode;\n\n        let mut output = vec![];\n\n        // write the version\n        encode::write_u16(&mut output, HISTORY_RECORD_VERSION_V1)?;\n        let include_intent = self.intent.is_some();\n        encode::write_array_len(&mut output, 10 + u32::from(include_intent))?;\n\n        encode::write_str(&mut output, &self.id.0)?;\n        encode::write_u64(&mut output, self.timestamp.unix_timestamp_nanos() as u64)?;\n        encode::write_sint(&mut output, self.duration)?;\n        encode::write_sint(&mut output, self.exit)?;\n        encode::write_str(&mut output, &self.command)?;\n        encode::write_str(&mut output, &self.cwd)?;\n        encode::write_str(&mut output, &self.session)?;\n        encode::write_str(&mut output, &self.hostname)?;\n\n        match self.deleted_at {\n            Some(d) => encode::write_u64(&mut output, d.unix_timestamp_nanos() as u64)?,\n            None => encode::write_nil(&mut output)?,\n        }\n\n        encode::write_str(&mut output, self.author.as_str())?;\n        if let Some(intent) = &self.intent {\n            encode::write_str(&mut output, intent.as_str())?;\n        }\n\n        Ok(DecryptedData(output))\n    }\n\n    fn read_optional_string(bytes: &[u8]) -> Result<(Option<String>, &[u8])> {\n        use rmp::decode;\n\n        fn error_report<E: std::fmt::Debug>(err: E) -> eyre::Report {\n            eyre!(\"{err:?}\")\n        }\n\n        match decode::read_str_from_slice(bytes) {\n            Ok((value, bytes)) => Ok((Some(value.to_owned()), bytes)),\n            Err(DecodeStringError::TypeMismatch(Marker::Null)) => {\n                let mut cursor = Bytes::new(bytes);\n                decode::read_nil(&mut cursor).map_err(error_report)?;\n\n                Ok((None, cursor.remaining_slice()))\n            }\n            Err(err) => Err(error_report(err)),\n        }\n    }\n\n    fn deserialize_v0(bytes: &[u8]) -> Result<History> {\n        use rmp::decode;\n\n        fn error_report<E: std::fmt::Debug>(err: E) -> eyre::Report {\n            eyre!(\"{err:?}\")\n        }\n\n        let mut bytes = Bytes::new(bytes);\n\n        let version = decode::read_u16(&mut bytes).map_err(error_report)?;\n\n        if version != HISTORY_RECORD_VERSION_V0 {\n            bail!(\"expected decoding v0 record, found v{version}\");\n        }\n\n        let nfields = decode::read_array_len(&mut bytes).map_err(error_report)?;\n\n        if nfields != 9 {\n            bail!(\"cannot decrypt history from a different version of Atuin\");\n        }\n\n        let bytes = bytes.remaining_slice();\n        let (id, bytes) = decode::read_str_from_slice(bytes).map_err(error_report)?;\n\n        let mut bytes = Bytes::new(bytes);\n        let timestamp = decode::read_u64(&mut bytes).map_err(error_report)?;\n        let duration = decode::read_int(&mut bytes).map_err(error_report)?;\n        let exit = decode::read_int(&mut bytes).map_err(error_report)?;\n\n        let bytes = bytes.remaining_slice();\n        let (command, bytes) = decode::read_str_from_slice(bytes).map_err(error_report)?;\n        let (cwd, bytes) = decode::read_str_from_slice(bytes).map_err(error_report)?;\n        let (session, bytes) = decode::read_str_from_slice(bytes).map_err(error_report)?;\n        let (hostname, bytes) = decode::read_str_from_slice(bytes).map_err(error_report)?;\n\n        let mut bytes = Bytes::new(bytes);\n\n        let (deleted_at, bytes) = match decode::read_u64(&mut bytes) {\n            Ok(unix) => (Some(unix), bytes.remaining_slice()),\n            // we accept null here\n            Err(ValueReadError::TypeMismatch(Marker::Null)) => (None, bytes.remaining_slice()),\n            Err(err) => return Err(error_report(err)),\n        };\n        if !bytes.is_empty() {\n            bail!(\"trailing bytes in encoded history. malformed\")\n        }\n\n        Ok(History {\n            id: id.to_owned().into(),\n            timestamp: OffsetDateTime::from_unix_timestamp_nanos(timestamp as i128)?,\n            duration,\n            exit,\n            command: command.to_owned(),\n            cwd: cwd.to_owned(),\n            session: session.to_owned(),\n            hostname: hostname.to_owned(),\n            author: Self::author_from_hostname(hostname),\n            intent: None,\n            deleted_at: deleted_at\n                .map(|t| OffsetDateTime::from_unix_timestamp_nanos(t as i128))\n                .transpose()?,\n        })\n    }\n\n    fn deserialize_v1(bytes: &[u8]) -> Result<History> {\n        use rmp::decode;\n\n        fn error_report<E: std::fmt::Debug>(err: E) -> eyre::Report {\n            eyre!(\"{err:?}\")\n        }\n\n        let mut bytes = Bytes::new(bytes);\n\n        let version = decode::read_u16(&mut bytes).map_err(error_report)?;\n\n        if version != HISTORY_RECORD_VERSION_V1 {\n            bail!(\"expected decoding v1 record, found v{version}\");\n        }\n\n        let nfields = decode::read_array_len(&mut bytes).map_err(error_report)?;\n\n        if !(10..=11).contains(&nfields) {\n            bail!(\"cannot decrypt history from a different version of Atuin\");\n        }\n\n        let bytes = bytes.remaining_slice();\n        let (id, bytes) = decode::read_str_from_slice(bytes).map_err(error_report)?;\n\n        let mut bytes = Bytes::new(bytes);\n        let timestamp = decode::read_u64(&mut bytes).map_err(error_report)?;\n        let duration = decode::read_int(&mut bytes).map_err(error_report)?;\n        let exit = decode::read_int(&mut bytes).map_err(error_report)?;\n\n        let bytes = bytes.remaining_slice();\n        let (command, bytes) = decode::read_str_from_slice(bytes).map_err(error_report)?;\n        let (cwd, bytes) = decode::read_str_from_slice(bytes).map_err(error_report)?;\n        let (session, bytes) = decode::read_str_from_slice(bytes).map_err(error_report)?;\n        let (hostname, bytes) = decode::read_str_from_slice(bytes).map_err(error_report)?;\n\n        let mut bytes = Bytes::new(bytes);\n\n        let (deleted_at, bytes) = match decode::read_u64(&mut bytes) {\n            Ok(unix) => (Some(unix), bytes.remaining_slice()),\n            // we accept null here\n            Err(ValueReadError::TypeMismatch(Marker::Null)) => (None, bytes.remaining_slice()),\n            Err(err) => return Err(error_report(err)),\n        };\n        let (author, bytes) = Self::read_optional_string(bytes)?;\n        let (intent, bytes) = if nfields > 10 {\n            Self::read_optional_string(bytes)?\n        } else {\n            (None, bytes)\n        };\n\n        if !bytes.is_empty() {\n            bail!(\"trailing bytes in encoded history. malformed\")\n        }\n\n        Ok(History {\n            id: id.to_owned().into(),\n            timestamp: OffsetDateTime::from_unix_timestamp_nanos(timestamp as i128)?,\n            duration,\n            exit,\n            command: command.to_owned(),\n            cwd: cwd.to_owned(),\n            session: session.to_owned(),\n            hostname: hostname.to_owned(),\n            author: author.unwrap_or_else(|| Self::author_from_hostname(hostname)),\n            intent,\n            deleted_at: deleted_at\n                .map(|t| OffsetDateTime::from_unix_timestamp_nanos(t as i128))\n                .transpose()?,\n        })\n    }\n\n    pub fn deserialize(bytes: &[u8], version: &str) -> Result<History> {\n        match version {\n            HISTORY_VERSION_V0 => Self::deserialize_v0(bytes),\n            HISTORY_VERSION_V1 => Self::deserialize_v1(bytes),\n\n            _ => bail!(\"unknown version {version:?}\"),\n        }\n    }\n\n    /// Builder for a history entry that is imported from shell history.\n    ///\n    /// The only two required fields are `timestamp` and `command`.\n    ///\n    /// ## Examples\n    /// ```\n    /// use atuin_client::history::History;\n    ///\n    /// let history: History = History::import()\n    ///     .timestamp(time::OffsetDateTime::now_utc())\n    ///     .command(\"ls -la\")\n    ///     .build()\n    ///     .into();\n    /// ```\n    ///\n    /// If shell history contains more information, it can be added to the builder:\n    /// ```\n    /// use atuin_client::history::History;\n    ///\n    /// let history: History = History::import()\n    ///     .timestamp(time::OffsetDateTime::now_utc())\n    ///     .command(\"ls -la\")\n    ///     .cwd(\"/home/user\")\n    ///     .exit(0)\n    ///     .duration(100)\n    ///     .build()\n    ///     .into();\n    /// ```\n    ///\n    /// Unknown command or command without timestamp cannot be imported, which\n    /// is forced at compile time:\n    ///\n    /// ```compile_fail\n    /// use atuin_client::history::History;\n    ///\n    /// // this will not compile because timestamp is missing\n    /// let history: History = History::import()\n    ///     .command(\"ls -la\")\n    ///     .build()\n    ///     .into();\n    /// ```\n    pub fn import() -> builder::HistoryImportedBuilder {\n        builder::HistoryImported::builder()\n    }\n\n    /// Builder for a history entry that is captured via hook.\n    ///\n    /// This builder is used only at the `start` step of the hook,\n    /// so it doesn't have any fields which are known only after\n    /// the command is finished, such as `exit` or `duration`.\n    ///\n    /// ## Examples\n    /// ```rust\n    /// use atuin_client::history::History;\n    ///\n    /// let history: History = History::capture()\n    ///     .timestamp(time::OffsetDateTime::now_utc())\n    ///     .command(\"ls -la\")\n    ///     .cwd(\"/home/user\")\n    ///     .build()\n    ///     .into();\n    /// ```\n    ///\n    /// Command without any required info cannot be captured, which is forced at compile time:\n    ///\n    /// ```compile_fail\n    /// use atuin_client::history::History;\n    ///\n    /// // this will not compile because `cwd` is missing\n    /// let history: History = History::capture()\n    ///     .timestamp(time::OffsetDateTime::now_utc())\n    ///     .command(\"ls -la\")\n    ///     .build()\n    ///     .into();\n    /// ```\n    pub fn capture() -> builder::HistoryCapturedBuilder {\n        builder::HistoryCaptured::builder()\n    }\n\n    /// Builder for a history entry that is captured via hook, and sent to the daemon.\n    ///\n    /// This builder is used only at the `start` step of the hook,\n    /// so it doesn't have any fields which are known only after\n    /// the command is finished, such as `exit` or `duration`.\n    ///\n    /// It does, however, include information that can usually be inferred.\n    ///\n    /// This is because the daemon we are sending a request to lacks the context of the command\n    ///\n    /// ## Examples\n    /// ```rust\n    /// use atuin_client::history::History;\n    ///\n    /// let history: History = History::daemon()\n    ///     .timestamp(time::OffsetDateTime::now_utc())\n    ///     .command(\"ls -la\")\n    ///     .cwd(\"/home/user\")\n    ///     .session(\"018deb6e8287781f9973ef40e0fde76b\")\n    ///     .hostname(\"computer:ellie\")\n    ///     .build()\n    ///     .into();\n    /// ```\n    ///\n    /// Command without any required info cannot be captured, which is forced at compile time:\n    ///\n    /// ```compile_fail\n    /// use atuin_client::history::History;\n    ///\n    /// // this will not compile because `hostname` is missing\n    /// let history: History = History::daemon()\n    ///     .timestamp(time::OffsetDateTime::now_utc())\n    ///     .command(\"ls -la\")\n    ///     .cwd(\"/home/user\")\n    ///     .session(\"018deb6e8287781f9973ef40e0fde76b\")\n    ///     .build()\n    ///     .into();\n    /// ```\n    pub fn daemon() -> builder::HistoryDaemonCaptureBuilder {\n        builder::HistoryDaemonCapture::builder()\n    }\n\n    /// Builder for a history entry that is imported from the database.\n    ///\n    /// All fields are required, as they are all present in the database.\n    ///\n    /// ```compile_fail\n    /// use atuin_client::history::History;\n    ///\n    /// // this will not compile because `id` field is missing\n    /// let history: History = History::from_db()\n    ///     .timestamp(time::OffsetDateTime::now_utc())\n    ///     .command(\"ls -la\".to_string())\n    ///     .cwd(\"/home/user\".to_string())\n    ///     .exit(0)\n    ///     .duration(100)\n    ///     .session(\"somesession\".to_string())\n    ///     .hostname(\"localhost\".to_string())\n    ///     .author(\"user\".to_string())\n    ///     .intent(None)\n    ///     .deleted_at(None)\n    ///     .build()\n    ///     .into();\n    /// ```\n    pub fn from_db() -> builder::HistoryFromDbBuilder {\n        builder::HistoryFromDb::builder()\n    }\n\n    pub fn success(&self) -> bool {\n        self.exit == 0 || self.duration == -1\n    }\n\n    pub fn should_save(&self, settings: &Settings) -> bool {\n        !(self.command.starts_with(' ')\n            || self.command.is_empty()\n            || settings.history_filter.is_match(&self.command)\n            || settings.cwd_filter.is_match(&self.cwd)\n            || (settings.secrets_filter && SECRET_PATTERNS_RE.is_match(&self.command)))\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use regex::RegexSet;\n    use time::macros::datetime;\n\n    use crate::{history::HISTORY_VERSION, settings::Settings};\n\n    use super::History;\n\n    // Test that we don't save history where necessary\n    #[test]\n    fn privacy_test() {\n        let settings = Settings {\n            cwd_filter: RegexSet::new([\"^/supasecret\"]).unwrap(),\n            history_filter: RegexSet::new([\"^psql\"]).unwrap(),\n            ..Settings::utc()\n        };\n\n        let normal_command: History = History::capture()\n            .timestamp(time::OffsetDateTime::now_utc())\n            .command(\"echo foo\")\n            .cwd(\"/\")\n            .build()\n            .into();\n\n        let with_space: History = History::capture()\n            .timestamp(time::OffsetDateTime::now_utc())\n            .command(\" echo bar\")\n            .cwd(\"/\")\n            .build()\n            .into();\n\n        let empty: History = History::capture()\n            .timestamp(time::OffsetDateTime::now_utc())\n            .command(\"\")\n            .cwd(\"/\")\n            .build()\n            .into();\n\n        let stripe_key: History = History::capture()\n            .timestamp(time::OffsetDateTime::now_utc())\n            .command(\"curl foo.com/bar?key=sk_test_1234567890abcdefghijklmnop\")\n            .cwd(\"/\")\n            .build()\n            .into();\n\n        let secret_dir: History = History::capture()\n            .timestamp(time::OffsetDateTime::now_utc())\n            .command(\"echo ohno\")\n            .cwd(\"/supasecret\")\n            .build()\n            .into();\n\n        let with_psql: History = History::capture()\n            .timestamp(time::OffsetDateTime::now_utc())\n            .command(\"psql\")\n            .cwd(\"/supasecret\")\n            .build()\n            .into();\n\n        assert!(normal_command.should_save(&settings));\n        assert!(!with_space.should_save(&settings));\n        assert!(!empty.should_save(&settings));\n        assert!(!stripe_key.should_save(&settings));\n        assert!(!secret_dir.should_save(&settings));\n        assert!(!with_psql.should_save(&settings));\n    }\n\n    #[test]\n    fn disable_secrets() {\n        let settings = Settings {\n            secrets_filter: false,\n            ..Settings::utc()\n        };\n\n        let stripe_key: History = History::capture()\n            .timestamp(time::OffsetDateTime::now_utc())\n            .command(\"curl foo.com/bar?key=sk_test_1234567890abcdefghijklmnop\")\n            .cwd(\"/\")\n            .build()\n            .into();\n\n        assert!(stripe_key.should_save(&settings));\n    }\n\n    #[test]\n    fn test_serialize_deserialize() {\n        let history = History {\n            id: \"66d16cbee7cd47538e5c5b8b44e9006e\".to_owned().into(),\n            timestamp: datetime!(2023-05-28 18:35:40.633872 +00:00),\n            duration: 49206000,\n            exit: 0,\n            command: \"git status\".to_owned(),\n            cwd: \"/Users/conrad.ludgate/Documents/code/atuin\".to_owned(),\n            session: \"b97d9a306f274473a203d2eba41f9457\".to_owned(),\n            hostname: \"fvfg936c0kpf:conrad.ludgate\".to_owned(),\n            author: \"conrad.ludgate\".to_owned(),\n            intent: None,\n            deleted_at: None,\n        };\n\n        let serialized = history.serialize().expect(\"failed to serialize history\");\n        assert_eq!(\n            &serialized.0[0..3],\n            [205, 0, 1],\n            \"should encode as history v1\"\n        );\n\n        let deserialized = History::deserialize(&serialized.0, HISTORY_VERSION)\n            .expect(\"failed to deserialize history\");\n        assert_eq!(history, deserialized);\n    }\n\n    #[test]\n    fn test_serialize_deserialize_deleted() {\n        let history = History {\n            id: \"66d16cbee7cd47538e5c5b8b44e9006e\".to_owned().into(),\n            timestamp: datetime!(2023-05-28 18:35:40.633872 +00:00),\n            duration: 49206000,\n            exit: 0,\n            command: \"git status\".to_owned(),\n            cwd: \"/Users/conrad.ludgate/Documents/code/atuin\".to_owned(),\n            session: \"b97d9a306f274473a203d2eba41f9457\".to_owned(),\n            hostname: \"fvfg936c0kpf:conrad.ludgate\".to_owned(),\n            author: \"conrad.ludgate\".to_owned(),\n            intent: None,\n            deleted_at: Some(datetime!(2023-11-19 20:18 +00:00)),\n        };\n\n        let serialized = history.serialize().expect(\"failed to serialize history\");\n\n        let deserialized = History::deserialize(&serialized.0, HISTORY_VERSION)\n            .expect(\"failed to deserialize history\");\n\n        assert_eq!(history, deserialized);\n    }\n\n    #[test]\n    fn test_serialize_deserialize_with_author_and_intent() {\n        let history = History {\n            id: \"66d16cbee7cd47538e5c5b8b44e9006e\".to_owned().into(),\n            timestamp: datetime!(2023-05-28 18:35:40.633872 +00:00),\n            duration: 49206000,\n            exit: 0,\n            command: \"git status\".to_owned(),\n            cwd: \"/Users/conrad.ludgate/Documents/code/atuin\".to_owned(),\n            session: \"b97d9a306f274473a203d2eba41f9457\".to_owned(),\n            hostname: \"fvfg936c0kpf:conrad.ludgate\".to_owned(),\n            author: \"claude\".to_owned(),\n            intent: Some(\"check repository status\".to_owned()),\n            deleted_at: None,\n        };\n\n        let serialized = history.serialize().expect(\"failed to serialize history\");\n        let deserialized = History::deserialize(&serialized.0, HISTORY_VERSION)\n            .expect(\"failed to deserialize history\");\n\n        assert_eq!(history, deserialized);\n    }\n\n    #[test]\n    fn test_serialize_deserialize_version() {\n        // v0\n        let bytes_v0 = [\n            205, 0, 0, 153, 217, 32, 54, 54, 100, 49, 54, 99, 98, 101, 101, 55, 99, 100, 52, 55,\n            53, 51, 56, 101, 53, 99, 53, 98, 56, 98, 52, 52, 101, 57, 48, 48, 54, 101, 207, 23, 99,\n            98, 117, 24, 210, 246, 128, 206, 2, 238, 210, 240, 0, 170, 103, 105, 116, 32, 115, 116,\n            97, 116, 117, 115, 217, 42, 47, 85, 115, 101, 114, 115, 47, 99, 111, 110, 114, 97, 100,\n            46, 108, 117, 100, 103, 97, 116, 101, 47, 68, 111, 99, 117, 109, 101, 110, 116, 115,\n            47, 99, 111, 100, 101, 47, 97, 116, 117, 105, 110, 217, 32, 98, 57, 55, 100, 57, 97,\n            51, 48, 54, 102, 50, 55, 52, 52, 55, 51, 97, 50, 48, 51, 100, 50, 101, 98, 97, 52, 49,\n            102, 57, 52, 53, 55, 187, 102, 118, 102, 103, 57, 51, 54, 99, 48, 107, 112, 102, 58,\n            99, 111, 110, 114, 97, 100, 46, 108, 117, 100, 103, 97, 116, 101, 192,\n        ];\n\n        let deserialized = History::deserialize(&bytes_v0, \"v0\");\n        assert!(deserialized.is_ok());\n\n        let deserialized = History::deserialize(&bytes_v0, HISTORY_VERSION);\n        assert!(deserialized.is_err());\n\n        let current = History {\n            id: \"66d16cbee7cd47538e5c5b8b44e9006e\".to_owned().into(),\n            timestamp: datetime!(2023-05-28 18:35:40.633872 +00:00),\n            duration: 49206000,\n            exit: 0,\n            command: \"git status\".to_owned(),\n            cwd: \"/Users/conrad.ludgate/Documents/code/atuin\".to_owned(),\n            session: \"b97d9a306f274473a203d2eba41f9457\".to_owned(),\n            hostname: \"fvfg936c0kpf:conrad.ludgate\".to_owned(),\n            author: \"conrad.ludgate\".to_owned(),\n            intent: None,\n            deleted_at: None,\n        };\n\n        let bytes_v1 = current.serialize().expect(\"failed to serialize history\");\n        let deserialized = History::deserialize(&bytes_v1.0, HISTORY_VERSION);\n        assert!(deserialized.is_ok());\n\n        let deserialized = History::deserialize(&bytes_v1.0, \"v0\");\n        assert!(deserialized.is_err());\n    }\n}\n"
  },
  {
    "path": "crates/atuin-client/src/hub.rs",
    "content": "//! Hub authentication support for Atuin\n//!\n//! This module provides programmatic access to the Atuin Hub authentication flow.\n//! It can be used by other crates (like atuin-ai) to authenticate with the Hub\n//! and obtain session tokens.\n//!\n//! Hub authentication is separate from sync authentication - users can have both\n//! a sync session (for history sync) and a hub session (for Hub-specific features\n//! like AI).\n\nuse std::time::Duration;\n\nuse eyre::{Context, Result, bail};\nuse reqwest::{StatusCode, Url, header::USER_AGENT};\n\nuse atuin_common::{\n    api::{\n        ATUIN_CARGO_VERSION, ATUIN_HEADER_VERSION, CliCodeResponse, CliVerifyResponse,\n        ErrorResponse,\n    },\n    tls::ensure_crypto_provider,\n};\n\nuse crate::settings::Settings;\n\nstatic APP_USER_AGENT: &str = concat!(\"atuin/\", env!(\"CARGO_PKG_VERSION\"));\n\n/// The result of starting a hub authentication flow\n#[derive(Debug, Clone)]\npub struct HubAuthSession {\n    /// The code to be verified\n    pub code: String,\n    /// The URL the user should visit to authenticate\n    pub auth_url: String,\n    /// The hub address being used\n    pub hub_address: String,\n}\n\n/// The result of polling for hub auth completion\n#[derive(Debug, Clone)]\npub enum HubAuthStatus {\n    /// Still waiting for user authorization\n    Pending,\n    /// Authorization complete, contains the session token\n    Complete(String),\n    /// Authorization failed with an error\n    Failed(String),\n}\n\n/// Default poll interval for checking auth status\npub const DEFAULT_POLL_INTERVAL: Duration = Duration::from_secs(2);\n\n/// Default timeout for the entire auth flow\npub const DEFAULT_AUTH_TIMEOUT: Duration = Duration::from_secs(600);\n\nimpl HubAuthSession {\n    /// Start a new hub authentication session\n    ///\n    /// Returns a session containing the code and auth URL that the user should visit.\n    pub async fn start(hub_address: &str) -> Result<Self> {\n        debug!(\"Starting Hub authentication process...\");\n\n        let hub_address = hub_address.trim_end_matches('/');\n        let code_response = request_code(hub_address)\n            .await\n            .context(\"Failed to request authentication code from Hub\")?;\n\n        debug!(\"Received code from Hub\");\n\n        let code = code_response.code;\n        let auth_url = format!(\"{}/auth/cli?code={}\", hub_address, code);\n\n        Ok(Self {\n            code,\n            auth_url,\n            hub_address: hub_address.to_string(),\n        })\n    }\n\n    /// Poll for the authentication status\n    ///\n    /// Returns the current status of the authentication flow.\n    pub async fn poll(&self) -> Result<HubAuthStatus> {\n        match verify_code(&self.hub_address, &self.code).await {\n            Ok(response) => {\n                if let Some(token) = response.token {\n                    debug!(\"Authentication complete, received token\");\n                    Ok(HubAuthStatus::Complete(token))\n                } else if let Some(error) = response.error {\n                    error!(\"Authentication failed: {}\", error);\n                    Ok(HubAuthStatus::Failed(error))\n                } else {\n                    Ok(HubAuthStatus::Pending)\n                }\n            }\n            Err(e) => {\n                // Transient errors shouldn't fail the whole flow\n                log::debug!(\"Verification poll failed: {}\", e);\n                Ok(HubAuthStatus::Pending)\n            }\n        }\n    }\n\n    /// Poll until completion or timeout\n    ///\n    /// This is a convenience method that polls repeatedly until the auth completes\n    /// or times out.\n    pub async fn wait_for_completion(\n        &self,\n        timeout: Duration,\n        poll_interval: Duration,\n    ) -> Result<String> {\n        let start = std::time::Instant::now();\n\n        debug!(\"Polling for Hub authentication completion...\");\n\n        loop {\n            if start.elapsed() > timeout {\n                warn!(\"Authentication loop exited due to timeout\");\n                bail!(\"Authentication timed out. Please try again.\");\n            }\n\n            match self.poll().await? {\n                HubAuthStatus::Complete(token) => return Ok(token),\n                HubAuthStatus::Failed(error) => bail!(\"Authentication failed: {}\", error),\n                HubAuthStatus::Pending => {\n                    tokio::time::sleep(poll_interval).await;\n                }\n            }\n        }\n    }\n}\n\n/// Save a hub session token\n///\n/// This saves the token to the meta store so it can be used for subsequent Hub API calls.\n/// Note: This is separate from the sync session token.\npub async fn save_session(token: &str) -> Result<()> {\n    Settings::meta_store()\n        .await?\n        .save_hub_session(token)\n        .await\n        .context(\"Failed to save hub session\")\n}\n\n/// Delete the hub session token (logout from Hub)\npub async fn delete_session() -> Result<()> {\n    Settings::meta_store()\n        .await?\n        .delete_hub_session()\n        .await\n        .context(\"Failed to delete hub session\")\n}\n\n/// Check if the user is logged in with Hub authentication\n///\n/// Returns true if the user has a valid Hub session token.\n/// This is independent of whether they have a sync session.\npub async fn is_logged_in() -> Result<bool> {\n    Settings::meta_store().await?.hub_logged_in().await\n}\n\n/// Get the hub session token if available\n///\n/// Returns the Hub session token if the user is logged in with Hub auth,\n/// or None if not logged in.\npub async fn get_session_token() -> Result<Option<String>> {\n    Settings::meta_store().await?.hub_session_token().await\n}\n\n/// Link an existing CLI sync account to the current Hub user.\n///\n/// This associates the CLI's sync records with the Hub account, enabling\n/// unified authentication. After linking:\n/// - The Hub token can be used for sync operations\n/// - Records are migrated to be accessible via Hub auth\n///\n/// Requires:\n/// - A valid Hub session (user must be logged in to Hub)\n/// - A valid CLI session token to link\n///\n/// Returns Ok(()) on success, or an error if:\n/// - Not logged in to Hub\n/// - CLI token is invalid\n/// - CLI account is already linked to a different Hub account\npub async fn link_account(hub_address: &str, cli_token: &str) -> Result<()> {\n    let hub_token = get_session_token()\n        .await?\n        .ok_or_else(|| eyre::eyre!(\"Not logged in to Hub - cannot link account\"))?;\n\n    let url = make_url(hub_address, \"/api/v0/account/link\")?;\n\n    debug!(\"Linking CLI account to Hub at {}\", hub_address);\n\n    ensure_crypto_provider();\n    let client = reqwest::Client::new();\n\n    let resp = client\n        .post(&url)\n        .header(USER_AGENT, APP_USER_AGENT)\n        .header(ATUIN_HEADER_VERSION, ATUIN_CARGO_VERSION)\n        .bearer_auth(&hub_token)\n        .json(&serde_json::json!({ \"token\": cli_token }))\n        .send()\n        .await?;\n\n    let status = resp.status();\n\n    if status == StatusCode::CONFLICT {\n        // 409 means CLI account is already linked to a (possibly different) Hub account\n        debug!(\"CLI account already linked to a Hub account\");\n        return Ok(());\n    }\n\n    handle_resp_error(resp).await?;\n\n    info!(\"Successfully linked CLI account to Hub\");\n    Ok(())\n}\n\n// --- Internal HTTP functions ---\n\nfn make_url(address: &str, path: &str) -> Result<String> {\n    let address = if address.ends_with('/') {\n        address.to_string()\n    } else {\n        format!(\"{address}/\")\n    };\n\n    let path = path.strip_prefix('/').unwrap_or(path);\n\n    let url = Url::parse(&address)\n        .context(\"failed to parse hub address\")?\n        .join(path)\n        .context(\"failed to join hub URL path\")?;\n\n    Ok(url.to_string())\n}\n\nasync fn handle_resp_error(resp: reqwest::Response) -> Result<reqwest::Response> {\n    let status = resp.status();\n\n    if status == StatusCode::SERVICE_UNAVAILABLE {\n        error!(\"Service unavailable: check https://status.atuin.sh\");\n        bail!(\"Service unavailable: check https://status.atuin.sh\");\n    }\n\n    if status == StatusCode::TOO_MANY_REQUESTS {\n        error!(\"Rate limited; please wait before trying again\");\n        bail!(\"Rate limited; please wait before trying again\");\n    }\n\n    if !status.is_success() {\n        if let Ok(error) = resp.json::<ErrorResponse>().await {\n            error!(\"Hub error: {} - {}\", status, error.reason);\n            bail!(\"Hub error: {} - {}\", status, error.reason);\n        }\n        error!(\"Hub request failed with status: {}\", status);\n        bail!(\"Hub request failed with status: {}\", status);\n    }\n\n    Ok(resp)\n}\n\n/// Request a CLI auth code from the Atuin Hub\nasync fn request_code(address: &str) -> Result<CliCodeResponse> {\n    ensure_crypto_provider();\n    let url = make_url(address, \"/auth/cli/code\")?;\n    let client = reqwest::Client::new();\n\n    debug!(\"Requesting code from Hub at {url}\");\n\n    let resp = client\n        .post(&url)\n        .header(USER_AGENT, APP_USER_AGENT)\n        .header(ATUIN_HEADER_VERSION, ATUIN_CARGO_VERSION)\n        .send()\n        .await?;\n    let resp = handle_resp_error(resp).await?;\n\n    let code_response = resp.json::<CliCodeResponse>().await?;\n    Ok(code_response)\n}\n\n/// Poll to verify the CLI auth code and get the session token\nasync fn verify_code(address: &str, code: &str) -> Result<CliVerifyResponse> {\n    ensure_crypto_provider();\n    let base = make_url(address, \"/auth/cli/verify\")?;\n    let url = format!(\"{base}?code={code}\");\n    let client = reqwest::Client::new();\n\n    debug!(\"Verifying code with Hub at {base}?code=******\");\n\n    let resp = client\n        .post(&url)\n        .header(USER_AGENT, APP_USER_AGENT)\n        .header(ATUIN_HEADER_VERSION, ATUIN_CARGO_VERSION)\n        .send()\n        .await?;\n    let resp = handle_resp_error(resp).await?;\n\n    let verify_response = resp.json::<CliVerifyResponse>().await?;\n    Ok(verify_response)\n}\n"
  },
  {
    "path": "crates/atuin-client/src/import/bash.rs",
    "content": "use std::{path::PathBuf, str};\n\nuse async_trait::async_trait;\nuse directories::UserDirs;\nuse eyre::{Result, eyre};\nuse itertools::Itertools;\nuse time::{Duration, OffsetDateTime};\n\nuse super::{Importer, Loader, get_histfile_path, unix_byte_lines};\nuse crate::history::History;\nuse crate::import::read_to_end;\n\n#[derive(Debug)]\npub struct Bash {\n    bytes: Vec<u8>,\n}\n\nfn default_histpath() -> Result<PathBuf> {\n    let user_dirs = UserDirs::new().ok_or_else(|| eyre!(\"could not find user directories\"))?;\n    let home_dir = user_dirs.home_dir();\n\n    Ok(home_dir.join(\".bash_history\"))\n}\n\n#[async_trait]\nimpl Importer for Bash {\n    const NAME: &'static str = \"bash\";\n\n    async fn new() -> Result<Self> {\n        let bytes = read_to_end(get_histfile_path(default_histpath)?)?;\n        Ok(Self { bytes })\n    }\n\n    async fn entries(&mut self) -> Result<usize> {\n        let count = unix_byte_lines(&self.bytes)\n            .map(LineType::from)\n            .filter(|line| matches!(line, LineType::Command(_)))\n            .count();\n        Ok(count)\n    }\n\n    async fn load(self, h: &mut impl Loader) -> Result<()> {\n        let lines = unix_byte_lines(&self.bytes)\n            .map(LineType::from)\n            .filter(|line| !matches!(line, LineType::NotUtf8)) // invalid utf8 are ignored\n            .collect_vec();\n\n        let (commands_before_first_timestamp, first_timestamp) = lines\n            .iter()\n            .enumerate()\n            .find_map(|(i, line)| match line {\n                LineType::Timestamp(t) => Some((i, *t)),\n                _ => None,\n            })\n            // if no known timestamps, use now as base\n            .unwrap_or((lines.len(), OffsetDateTime::now_utc()));\n\n        // if no timestamp is recorded, then use this increment to set an arbitrary timestamp\n        // to preserve ordering\n        // this increment is deliberately very small to prevent particularly fast fingers\n        // causing ordering issues; it also helps in handling the \"here document\" syntax,\n        // where several lines are recorded in succession without individual timestamps\n        let timestamp_increment = Duration::milliseconds(1);\n\n        // make sure there is a minimum amount of time before the first known timestamp\n        // to fit all commands, given the default increment\n        let mut next_timestamp =\n            first_timestamp - timestamp_increment * commands_before_first_timestamp as i32;\n\n        for line in lines.into_iter() {\n            match line {\n                LineType::NotUtf8 => unreachable!(), // already filtered\n                LineType::Empty => {}                // do nothing\n                LineType::Timestamp(t) => {\n                    if t < next_timestamp {\n                        warn!(\n                            \"Time reversal detected in Bash history! Commands may be ordered incorrectly.\"\n                        );\n                    }\n                    next_timestamp = t;\n                }\n                LineType::Command(c) => {\n                    let imported = History::import().timestamp(next_timestamp).command(c);\n\n                    h.push(imported.build().into()).await?;\n                    next_timestamp += timestamp_increment;\n                }\n            }\n        }\n\n        Ok(())\n    }\n}\n\n#[derive(Debug, Clone)]\nenum LineType<'a> {\n    NotUtf8,\n    /// Can happen when using the \"here document\" syntax.\n    Empty,\n    /// A timestamp line start with a '#', followed immediately by an integer\n    /// that represents seconds since UNIX epoch.\n    Timestamp(OffsetDateTime),\n    /// Anything else.\n    Command(&'a str),\n}\nimpl<'a> From<&'a [u8]> for LineType<'a> {\n    fn from(bytes: &'a [u8]) -> Self {\n        let Ok(line) = str::from_utf8(bytes) else {\n            return LineType::NotUtf8;\n        };\n        if line.is_empty() {\n            return LineType::Empty;\n        }\n\n        match try_parse_line_as_timestamp(line) {\n            Some(time) => LineType::Timestamp(time),\n            None => LineType::Command(line),\n        }\n    }\n}\n\nfn try_parse_line_as_timestamp(line: &str) -> Option<OffsetDateTime> {\n    let seconds = line.strip_prefix('#')?.parse().ok()?;\n    OffsetDateTime::from_unix_timestamp(seconds).ok()\n}\n\n#[cfg(test)]\nmod test {\n    use std::cmp::Ordering;\n\n    use itertools::{Itertools, assert_equal};\n\n    use crate::import::{Importer, tests::TestLoader};\n\n    use super::Bash;\n\n    #[tokio::test]\n    async fn parse_no_timestamps() {\n        let bytes = r\"cargo install atuin\ncargo update\ncargo :b̷i̶t̴r̵o̴t̴ ̵i̷s̴ ̷r̶e̵a̸l̷\n\"\n        .as_bytes()\n        .to_owned();\n\n        let mut bash = Bash { bytes };\n        assert_eq!(bash.entries().await.unwrap(), 3);\n\n        let mut loader = TestLoader::default();\n        bash.load(&mut loader).await.unwrap();\n\n        assert_equal(\n            loader.buf.iter().map(|h| h.command.as_str()),\n            [\n                \"cargo install atuin\",\n                \"cargo update\",\n                \"cargo :b̷i̶t̴r̵o̴t̴ ̵i̷s̴ ̷r̶e̵a̸l̷\",\n            ],\n        );\n        assert!(is_strictly_sorted(loader.buf.iter().map(|h| h.timestamp)))\n    }\n\n    #[tokio::test]\n    async fn parse_with_timestamps() {\n        let bytes = b\"#1672918999\ngit reset\n#1672919006\ngit clean -dxf\n#1672919020\ncd ../\n\"\n        .to_vec();\n\n        let mut bash = Bash { bytes };\n        assert_eq!(bash.entries().await.unwrap(), 3);\n\n        let mut loader = TestLoader::default();\n        bash.load(&mut loader).await.unwrap();\n\n        assert_equal(\n            loader.buf.iter().map(|h| h.command.as_str()),\n            [\"git reset\", \"git clean -dxf\", \"cd ../\"],\n        );\n        assert_equal(\n            loader.buf.iter().map(|h| h.timestamp.unix_timestamp()),\n            [1672918999, 1672919006, 1672919020],\n        )\n    }\n\n    #[tokio::test]\n    async fn parse_with_partial_timestamps() {\n        let bytes = b\"git reset\n#1672919006\ngit clean -dxf\ncd ../\n\"\n        .to_vec();\n\n        let mut bash = Bash { bytes };\n        assert_eq!(bash.entries().await.unwrap(), 3);\n\n        let mut loader = TestLoader::default();\n        bash.load(&mut loader).await.unwrap();\n\n        assert_equal(\n            loader.buf.iter().map(|h| h.command.as_str()),\n            [\"git reset\", \"git clean -dxf\", \"cd ../\"],\n        );\n        assert!(is_strictly_sorted(loader.buf.iter().map(|h| h.timestamp)))\n    }\n\n    fn is_strictly_sorted<T>(iter: impl IntoIterator<Item = T>) -> bool\n    where\n        T: Clone + PartialOrd,\n    {\n        iter.into_iter()\n            .tuple_windows()\n            .all(|(a, b)| matches!(a.partial_cmp(&b), Some(Ordering::Less)))\n    }\n}\n"
  },
  {
    "path": "crates/atuin-client/src/import/fish.rs",
    "content": "// import old shell history!\n// automatically hoover up all that we can find\n\nuse std::path::PathBuf;\n\nuse async_trait::async_trait;\nuse directories::BaseDirs;\nuse eyre::{Result, eyre};\nuse time::OffsetDateTime;\n\nuse super::{Importer, Loader, unix_byte_lines};\nuse crate::history::History;\nuse crate::import::read_to_end;\n\n#[derive(Debug)]\npub struct Fish {\n    bytes: Vec<u8>,\n}\n\n/// see https://fishshell.com/docs/current/interactive.html#searchable-command-history\nfn default_histpath() -> Result<PathBuf> {\n    let base = BaseDirs::new().ok_or_else(|| eyre!(\"could not determine data directory\"))?;\n    let data = std::env::var(\"XDG_DATA_HOME\").map_or_else(\n        |_| base.home_dir().join(\".local\").join(\"share\"),\n        PathBuf::from,\n    );\n\n    // fish supports multiple history sessions\n    // If `fish_history` var is missing, or set to `default`, use `fish` as the session\n    let session = std::env::var(\"fish_history\").unwrap_or_else(|_| String::from(\"fish\"));\n    let session = if session == \"default\" {\n        String::from(\"fish\")\n    } else {\n        session\n    };\n\n    let mut histpath = data.join(\"fish\");\n    histpath.push(format!(\"{session}_history\"));\n\n    if histpath.exists() {\n        Ok(histpath)\n    } else {\n        Err(eyre!(\"Could not find history file.\"))\n    }\n}\n\n#[async_trait]\nimpl Importer for Fish {\n    const NAME: &'static str = \"fish\";\n\n    async fn new() -> Result<Self> {\n        let bytes = read_to_end(default_histpath()?)?;\n        Ok(Self { bytes })\n    }\n\n    async fn entries(&mut self) -> Result<usize> {\n        Ok(super::count_lines(&self.bytes))\n    }\n\n    async fn load(self, loader: &mut impl Loader) -> Result<()> {\n        let now = OffsetDateTime::now_utc();\n        let mut time: Option<OffsetDateTime> = None;\n        let mut cmd: Option<String> = None;\n\n        for b in unix_byte_lines(&self.bytes) {\n            let s = match std::str::from_utf8(b) {\n                Ok(s) => s,\n                Err(_) => continue, // we can skip past things like invalid utf8\n            };\n\n            if let Some(c) = s.strip_prefix(\"- cmd: \") {\n                // first, we must deal with the prev cmd\n                if let Some(cmd) = cmd.take() {\n                    let time = time.unwrap_or(now);\n                    let entry = History::import().timestamp(time).command(cmd);\n\n                    loader.push(entry.build().into()).await?;\n                }\n\n                // using raw strings to avoid needing escaping.\n                // replaces double backslashes with single backslashes\n                let c = c.replace(r\"\\\\\", r\"\\\");\n                // replaces escaped newlines\n                let c = c.replace(r\"\\n\", \"\\n\");\n                // TODO: any other escape characters?\n\n                cmd = Some(c);\n            } else if let Some(t) = s.strip_prefix(\"  when: \") {\n                // if t is not an int, just ignore this line\n                if let Ok(t) = t.parse::<i64>() {\n                    time = Some(OffsetDateTime::from_unix_timestamp(t)?);\n                }\n            } else {\n                // ... ignore paths lines\n            }\n        }\n\n        // we might have a trailing cmd\n        if let Some(cmd) = cmd.take() {\n            let time = time.unwrap_or(now);\n            let entry = History::import().timestamp(time).command(cmd);\n\n            loader.push(entry.build().into()).await?;\n        }\n\n        Ok(())\n    }\n}\n\n#[cfg(test)]\nmod test {\n\n    use crate::import::{Importer, tests::TestLoader};\n\n    use super::Fish;\n\n    #[tokio::test]\n    async fn parse_complex() {\n        // complicated input with varying contents and escaped strings.\n        let bytes = r#\"- cmd: history --help\n  when: 1639162832\n- cmd: cat ~/.bash_history\n  when: 1639162851\n  paths:\n    - ~/.bash_history\n- cmd: ls ~/.local/share/fish/fish_history\n  when: 1639162890\n  paths:\n    - ~/.local/share/fish/fish_history\n- cmd: cat ~/.local/share/fish/fish_history\n  when: 1639162893\n  paths:\n    - ~/.local/share/fish/fish_history\nERROR\n- CORRUPTED: ENTRY\n  CONTINUE:\n    - AS\n    - NORMAL\n- cmd: echo \"foo\" \\\\\\n'bar' baz\n  when: 1639162933\n- cmd: cat ~/.local/share/fish/fish_history\n  when: 1639162939\n  paths:\n    - ~/.local/share/fish/fish_history\n- cmd: echo \"\\\\\"\" \\\\\\\\ \"\\\\\\\\\"\n  when: 1639163063\n- cmd: cat ~/.local/share/fish/fish_history\n  when: 1639163066\n  paths:\n    - ~/.local/share/fish/fish_history\n\"#\n        .as_bytes()\n        .to_owned();\n\n        let fish = Fish { bytes };\n\n        let mut loader = TestLoader::default();\n        fish.load(&mut loader).await.unwrap();\n        let mut history = loader.buf.into_iter();\n\n        // simple wrapper for fish history entry\n        macro_rules! fishtory {\n            ($timestamp:expr_2021, $command:expr_2021) => {\n                let h = history.next().expect(\"missing entry in history\");\n                assert_eq!(h.command.as_str(), $command);\n                assert_eq!(h.timestamp.unix_timestamp(), $timestamp);\n            };\n        }\n\n        fishtory!(1639162832, \"history --help\");\n        fishtory!(1639162851, \"cat ~/.bash_history\");\n        fishtory!(1639162890, \"ls ~/.local/share/fish/fish_history\");\n        fishtory!(1639162893, \"cat ~/.local/share/fish/fish_history\");\n        fishtory!(1639162933, \"echo \\\"foo\\\" \\\\\\n'bar' baz\");\n        fishtory!(1639162939, \"cat ~/.local/share/fish/fish_history\");\n        fishtory!(1639163063, r#\"echo \"\\\"\" \\\\ \"\\\\\"\"#);\n        fishtory!(1639163066, \"cat ~/.local/share/fish/fish_history\");\n    }\n}\n"
  },
  {
    "path": "crates/atuin-client/src/import/mod.rs",
    "content": "use std::fs::File;\nuse std::io::Read;\nuse std::path::PathBuf;\n\nuse async_trait::async_trait;\nuse eyre::{Result, bail};\nuse memchr::Memchr;\n\nuse crate::history::History;\n\npub mod bash;\npub mod fish;\npub mod nu;\npub mod nu_histdb;\npub mod powershell;\npub mod replxx;\npub mod resh;\npub mod xonsh;\npub mod xonsh_sqlite;\npub mod zsh;\npub mod zsh_histdb;\n\n#[async_trait]\npub trait Importer: Sized {\n    const NAME: &'static str;\n    async fn new() -> Result<Self>;\n    async fn entries(&mut self) -> Result<usize>;\n    async fn load(self, loader: &mut impl Loader) -> Result<()>;\n}\n\n#[async_trait]\npub trait Loader: Sync + Send {\n    async fn push(&mut self, hist: History) -> eyre::Result<()>;\n}\n\nfn unix_byte_lines(input: &[u8]) -> impl Iterator<Item = &[u8]> {\n    UnixByteLines {\n        iter: memchr::memchr_iter(b'\\n', input),\n        bytes: input,\n        i: 0,\n    }\n}\n\nstruct UnixByteLines<'a> {\n    iter: Memchr<'a>,\n    bytes: &'a [u8],\n    i: usize,\n}\n\nimpl<'a> Iterator for UnixByteLines<'a> {\n    type Item = &'a [u8];\n\n    fn next(&mut self) -> Option<Self::Item> {\n        let j = self.iter.next()?;\n        let out = &self.bytes[self.i..j];\n        self.i = j + 1;\n        Some(out)\n    }\n\n    fn count(self) -> usize\n    where\n        Self: Sized,\n    {\n        self.iter.count()\n    }\n}\n\nfn count_lines(input: &[u8]) -> usize {\n    unix_byte_lines(input).count()\n}\n\nfn get_histpath<D>(def: D) -> Result<PathBuf>\nwhere\n    D: FnOnce() -> Result<PathBuf>,\n{\n    if let Ok(p) = std::env::var(\"HISTFILE\") {\n        Ok(PathBuf::from(p))\n    } else {\n        def()\n    }\n}\n\nfn get_histfile_path<D>(def: D) -> Result<PathBuf>\nwhere\n    D: FnOnce() -> Result<PathBuf>,\n{\n    get_histpath(def).and_then(is_file)\n}\n\nfn get_histdir_path<D>(def: D) -> Result<PathBuf>\nwhere\n    D: FnOnce() -> Result<PathBuf>,\n{\n    get_histpath(def).and_then(is_dir)\n}\n\nfn read_to_end(path: PathBuf) -> Result<Vec<u8>> {\n    let mut bytes = Vec::new();\n    let mut f = File::open(path)?;\n    f.read_to_end(&mut bytes)?;\n    Ok(bytes)\n}\nfn is_file(p: PathBuf) -> Result<PathBuf> {\n    if p.is_file() {\n        Ok(p)\n    } else {\n        bail!(\n            \"Could not find history file {:?}. Try setting and exporting $HISTFILE\",\n            p\n        )\n    }\n}\nfn is_dir(p: PathBuf) -> Result<PathBuf> {\n    if p.is_dir() {\n        Ok(p)\n    } else {\n        bail!(\n            \"Could not find history directory {:?}. Try setting and exporting $HISTFILE\",\n            p\n        )\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[derive(Default)]\n    pub struct TestLoader {\n        pub buf: Vec<History>,\n    }\n\n    #[async_trait]\n    impl Loader for TestLoader {\n        async fn push(&mut self, hist: History) -> Result<()> {\n            self.buf.push(hist);\n            Ok(())\n        }\n    }\n}\n"
  },
  {
    "path": "crates/atuin-client/src/import/nu.rs",
    "content": "// import old shell history!\n// automatically hoover up all that we can find\n\nuse std::path::PathBuf;\n\nuse async_trait::async_trait;\nuse directories::BaseDirs;\nuse eyre::{Result, eyre};\nuse time::OffsetDateTime;\n\nuse super::{Importer, Loader, unix_byte_lines};\nuse crate::history::History;\nuse crate::import::read_to_end;\n\n#[derive(Debug)]\npub struct Nu {\n    bytes: Vec<u8>,\n}\n\nfn get_histpath() -> Result<PathBuf> {\n    let base = BaseDirs::new().ok_or_else(|| eyre!(\"could not determine data directory\"))?;\n    let config_dir = base.config_dir().join(\"nushell\");\n\n    let histpath = config_dir.join(\"history.txt\");\n    if histpath.exists() {\n        Ok(histpath)\n    } else {\n        Err(eyre!(\"Could not find history file.\"))\n    }\n}\n\n#[async_trait]\nimpl Importer for Nu {\n    const NAME: &'static str = \"nu\";\n\n    async fn new() -> Result<Self> {\n        let bytes = read_to_end(get_histpath()?)?;\n        Ok(Self { bytes })\n    }\n\n    async fn entries(&mut self) -> Result<usize> {\n        Ok(super::count_lines(&self.bytes))\n    }\n\n    async fn load(self, h: &mut impl Loader) -> Result<()> {\n        let now = OffsetDateTime::now_utc();\n\n        let mut counter = 0;\n        for b in unix_byte_lines(&self.bytes) {\n            let s = match std::str::from_utf8(b) {\n                Ok(s) => s,\n                Err(_) => continue, // we can skip past things like invalid utf8\n            };\n\n            let cmd: String = s.replace(\"<\\\\n>\", \"\\n\");\n\n            let offset = time::Duration::nanoseconds(counter);\n            counter += 1;\n\n            let entry = History::import().timestamp(now - offset).command(cmd);\n\n            h.push(entry.build().into()).await?;\n        }\n\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "crates/atuin-client/src/import/nu_histdb.rs",
    "content": "// import old shell history!\n// automatically hoover up all that we can find\n\nuse std::path::PathBuf;\n\nuse async_trait::async_trait;\nuse directories::BaseDirs;\nuse eyre::{Result, eyre};\nuse sqlx::{Pool, sqlite::SqlitePool};\nuse time::{Duration, OffsetDateTime};\n\nuse super::Importer;\nuse crate::history::History;\nuse crate::import::Loader;\n\n#[derive(sqlx::FromRow, Debug)]\npub struct HistDbEntry {\n    pub id: i64,\n    pub command_line: Vec<u8>,\n    pub start_timestamp: i64,\n    pub session_id: i64,\n    pub hostname: Vec<u8>,\n    pub cwd: Vec<u8>,\n    pub duration_ms: i64,\n    pub exit_status: i64,\n    pub more_info: Vec<u8>,\n}\n\nimpl From<HistDbEntry> for History {\n    fn from(histdb_item: HistDbEntry) -> Self {\n        let ts_secs = histdb_item.start_timestamp / 1000;\n        let ts_ns = (histdb_item.start_timestamp % 1000) * 1_000_000;\n        let imported = History::import()\n            .timestamp(\n                OffsetDateTime::from_unix_timestamp(ts_secs).unwrap()\n                    + Duration::nanoseconds(ts_ns),\n            )\n            .command(String::from_utf8(histdb_item.command_line).unwrap())\n            .cwd(String::from_utf8(histdb_item.cwd).unwrap())\n            .exit(histdb_item.exit_status)\n            .duration(histdb_item.duration_ms)\n            .session(format!(\"{:x}\", histdb_item.session_id))\n            .hostname(String::from_utf8(histdb_item.hostname).unwrap());\n\n        imported.build().into()\n    }\n}\n\n#[derive(Debug)]\npub struct NuHistDb {\n    histdb: Vec<HistDbEntry>,\n}\n\n/// Read db at given file, return vector of entries.\nasync fn hist_from_db(dbpath: PathBuf) -> Result<Vec<HistDbEntry>> {\n    let pool = SqlitePool::connect(dbpath.to_str().unwrap()).await?;\n    hist_from_db_conn(pool).await\n}\n\nasync fn hist_from_db_conn(pool: Pool<sqlx::Sqlite>) -> Result<Vec<HistDbEntry>> {\n    let query = r#\"\n        SELECT\n            id, command_line, start_timestamp, session_id, hostname, cwd, duration_ms, exit_status,\n            more_info\n        FROM history\n        ORDER BY start_timestamp\n    \"#;\n    let histdb_vec: Vec<HistDbEntry> = sqlx::query_as::<_, HistDbEntry>(query)\n        .fetch_all(&pool)\n        .await?;\n    Ok(histdb_vec)\n}\n\nimpl NuHistDb {\n    pub fn histpath() -> Result<PathBuf> {\n        let base = BaseDirs::new().ok_or_else(|| eyre!(\"could not determine data directory\"))?;\n        let config_dir = base.config_dir().join(\"nushell\");\n\n        let histdb_path = config_dir.join(\"history.sqlite3\");\n        if histdb_path.exists() {\n            Ok(histdb_path)\n        } else {\n            Err(eyre!(\"Could not find history file.\"))\n        }\n    }\n}\n\n#[async_trait]\nimpl Importer for NuHistDb {\n    // Not sure how this is used\n    const NAME: &'static str = \"nu_histdb\";\n\n    /// Creates a new NuHistDb and populates the history based on the pre-populated data\n    /// structure.\n    async fn new() -> Result<Self> {\n        let dbpath = NuHistDb::histpath()?;\n        let histdb_entry_vec = hist_from_db(dbpath).await?;\n        Ok(Self {\n            histdb: histdb_entry_vec,\n        })\n    }\n\n    async fn entries(&mut self) -> Result<usize> {\n        Ok(self.histdb.len())\n    }\n\n    async fn load(self, h: &mut impl Loader) -> Result<()> {\n        for i in self.histdb {\n            h.push(i.into()).await?;\n        }\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "crates/atuin-client/src/import/powershell.rs",
    "content": "use async_trait::async_trait;\nuse directories::BaseDirs;\nuse eyre::{Result, eyre};\nuse std::path::PathBuf;\nuse time::{Duration, OffsetDateTime};\n\nuse super::{Importer, Loader, count_lines, unix_byte_lines};\nuse crate::history::History;\nuse crate::import::read_to_end;\n\n#[derive(Debug)]\npub struct PowerShell {\n    bytes: Vec<u8>,\n    line_count: Option<usize>,\n}\n\nfn get_history_path() -> Result<PathBuf> {\n    let base = BaseDirs::new().ok_or_else(|| eyre!(\"could not determine data directory\"))?;\n\n    // The command line history in PowerShell is maintained by the PSReadLine module:\n    // https://learn.microsoft.com/en-us/powershell/module/psreadline/about/about_psreadline#command-history\n    //\n    // > PSReadLine maintains a history file containing all the commands and data you've entered from the command line.\n    // > The history files are a file named `$($Host.Name)_history.txt`.\n    // > On Windows systems the history file is stored at `$Env:APPDATA\\Microsoft\\Windows\\PowerShell\\PSReadLine`.\n    // > On non-Windows systems, the history files are stored at `$Env:XDG_DATA_HOME/powershell/PSReadLine`\n    // > or `$Env:HOME/.local/share/powershell/PSReadLine`.\n\n    let dir = if cfg!(windows) {\n        base.data_dir()\n            .join(\"Microsoft\")\n            .join(\"Windows\")\n            .join(\"PowerShell\")\n            .join(\"PSReadLine\")\n    } else {\n        std::env::var(\"XDG_DATA_HOME\")\n            .map_or_else(\n                |_| base.home_dir().join(\".local\").join(\"share\"),\n                PathBuf::from,\n            )\n            .join(\"powershell\")\n            .join(\"PSReadLine\")\n    };\n\n    // The history is stored in a file named `$($Host.Name)_history.txt`.\n    // For the default console host shipped by Microsoft,`$Host.Name` is `ConsoleHost`:\n    // https://learn.microsoft.com/en-us/dotnet/api/system.management.automation.host.pshost.name#remarks\n\n    let file = dir.join(\"ConsoleHost_history.txt\");\n\n    if file.is_file() {\n        Ok(file)\n    } else {\n        Err(eyre!(\"Could not find history file: {}\", file.display()))\n    }\n}\n\n#[async_trait]\nimpl Importer for PowerShell {\n    const NAME: &'static str = \"PowerShell\";\n\n    async fn new() -> Result<Self> {\n        let bytes = read_to_end(get_history_path()?)?;\n        Ok(Self {\n            bytes,\n            line_count: None,\n        })\n    }\n\n    async fn entries(&mut self) -> Result<usize> {\n        // Commands can be split over multiple lines,\n        // but this is only used for a progress bar, and multi-line commands\n        // should be quite rare, so this is not an issue in practice.\n        if self.line_count.is_none() {\n            self.line_count = Some(count_lines(&self.bytes));\n        }\n        Ok(self.line_count.unwrap())\n    }\n\n    async fn load(mut self, h: &mut impl Loader) -> Result<()> {\n        let line_count = self.entries().await?;\n        let start = OffsetDateTime::now_utc() - Duration::milliseconds(line_count as i64);\n\n        let mut counter = 0;\n        let mut iter = unix_byte_lines(&self.bytes);\n\n        while let Some(s) = iter.next() {\n            let Ok(s) = read_line(s) else {\n                continue; // We can skip past things like invalid utf8\n            };\n\n            let mut cmd = s.to_string();\n\n            // Multi-line commands end with a backtick, append the following lines.\n            while cmd.ends_with('`') {\n                cmd.pop();\n\n                let Some(next) = iter.next() else {\n                    break;\n                };\n                let Ok(next) = read_line(next) else {\n                    break;\n                };\n\n                cmd.push('\\n');\n                cmd.push_str(next);\n            }\n\n            if cmd.is_empty() {\n                continue;\n            }\n\n            let offset = Duration::milliseconds(counter);\n            counter += 1;\n\n            let entry = History::import().timestamp(start + offset).command(cmd);\n            h.push(entry.build().into()).await?;\n        }\n\n        Ok(())\n    }\n}\n\nfn read_line(s: &[u8]) -> Result<&str> {\n    let s = str::from_utf8(s)?;\n\n    // History is stored in CRLF on Windows, normalize the input to LF on all platforms.\n    let s = s.strip_suffix('\\r').unwrap_or(s);\n\n    Ok(s)\n}\n\n#[cfg(test)]\nmod test {\n    use super::*;\n    use crate::import::tests::TestLoader;\n    use itertools::assert_equal;\n\n    const INPUT: &str = r#\"cargo install atuin\ncargo update\necho \"first line`\nsecond line`\n`\nlast line\"\necho foo\n\necho bar\necho baz\n\"#;\n\n    const EXPECTED: &[&str] = &[\n        \"cargo install atuin\",\n        \"cargo update\",\n        \"echo \\\"first line\\nsecond line\\n\\nlast line\\\"\",\n        \"echo foo\",\n        \"echo bar\",\n        \"echo baz\",\n    ];\n\n    #[tokio::test]\n    async fn test_import() {\n        let loader = import(INPUT).await;\n\n        let actual = loader.buf.iter().map(|h| h.command.clone());\n        let expected = EXPECTED.iter().map(|s| s.to_string());\n\n        assert_equal(actual, expected);\n    }\n\n    #[tokio::test]\n    async fn test_crlf() {\n        let input = INPUT.replace(\"\\n\", \"\\r\\n\");\n        let loader = import(input.as_str()).await;\n\n        let actual = loader.buf.iter().map(|h| h.command.clone());\n        let expected = EXPECTED.iter().map(|s| s.to_string());\n\n        assert_equal(actual, expected);\n    }\n\n    #[tokio::test]\n    async fn test_timestamps() {\n        let loader = import(INPUT).await;\n\n        let mut prev = loader.buf.first().unwrap().timestamp;\n        for current in loader.buf.iter().skip(1).map(|h| h.timestamp) {\n            assert!(current > prev);\n            prev = current;\n        }\n    }\n\n    async fn import(input: &str) -> TestLoader {\n        let powershell = PowerShell {\n            bytes: input.as_bytes().to_vec(),\n            line_count: None,\n        };\n\n        let mut loader = TestLoader::default();\n        powershell.load(&mut loader).await.unwrap();\n        loader\n    }\n}\n"
  },
  {
    "path": "crates/atuin-client/src/import/replxx.rs",
    "content": "use std::{path::PathBuf, str};\n\nuse async_trait::async_trait;\nuse directories::UserDirs;\nuse eyre::{Result, eyre};\nuse time::{OffsetDateTime, PrimitiveDateTime, macros::format_description};\n\nuse super::{Importer, Loader, get_histfile_path, unix_byte_lines};\nuse crate::history::History;\nuse crate::import::read_to_end;\n\n#[derive(Debug)]\npub struct Replxx {\n    bytes: Vec<u8>,\n}\n\nfn default_histpath() -> Result<PathBuf> {\n    let user_dirs = UserDirs::new().ok_or_else(|| eyre!(\"could not find user directories\"))?;\n    let home_dir = user_dirs.home_dir();\n\n    // There is no default histfile for replxx.\n    // Here we try a couple of common names.\n    let mut candidates = [\"replxx_history.txt\", \".histfile\"].iter();\n    loop {\n        match candidates.next() {\n            Some(candidate) => {\n                let histpath = home_dir.join(candidate);\n                if histpath.exists() {\n                    break Ok(histpath);\n                }\n            }\n            None => {\n                break Err(eyre!(\n                    \"Could not find history file. Try setting and exporting $HISTFILE\"\n                ));\n            }\n        }\n    }\n}\n\n#[async_trait]\nimpl Importer for Replxx {\n    const NAME: &'static str = \"replxx\";\n\n    async fn new() -> Result<Self> {\n        let bytes = read_to_end(get_histfile_path(default_histpath)?)?;\n        Ok(Self { bytes })\n    }\n\n    async fn entries(&mut self) -> Result<usize> {\n        Ok(super::count_lines(&self.bytes) / 2)\n    }\n\n    async fn load(self, h: &mut impl Loader) -> Result<()> {\n        let mut timestamp = OffsetDateTime::UNIX_EPOCH;\n\n        for b in unix_byte_lines(&self.bytes) {\n            let s = std::str::from_utf8(b)?;\n            match try_parse_line_as_timestamp(s) {\n                Some(t) => timestamp = t,\n                None => {\n                    // replxx uses ETB character (0x17) as line breaker\n                    let cmd = s.replace('\\u{0017}', \"\\n\");\n                    let imported = History::import().timestamp(timestamp).command(cmd);\n\n                    h.push(imported.build().into()).await?;\n                }\n            }\n        }\n\n        Ok(())\n    }\n}\n\nfn try_parse_line_as_timestamp(line: &str) -> Option<OffsetDateTime> {\n    // replxx history date time format: ### yyyy-mm-dd hh:mm:ss.xxx\n    let date_time_str = line.strip_prefix(\"### \")?;\n    let format =\n        format_description!(\"[year]-[month]-[day] [hour]:[minute]:[second].[subsecond digits:3]\");\n\n    let primitive_date_time = PrimitiveDateTime::parse(date_time_str, format).ok()?;\n    // There is no safe way to get local time offset.\n    // For simplicity let's just assume UTC.\n    Some(primitive_date_time.assume_utc())\n}\n\n#[cfg(test)]\nmod test {\n\n    use crate::import::{Importer, tests::TestLoader};\n\n    use super::Replxx;\n\n    #[tokio::test]\n    async fn parse_complex() {\n        let bytes = r#\"### 2024-02-10 22:16:28.302\nselect * from remote('127.0.0.1:20222', view(select 1))\n### 2024-02-10 22:16:36.919\nselect * from numbers(10)\n### 2024-02-10 22:16:41.710\nselect * from system.numbers\n### 2024-02-10 22:19:28.655\nselect 1\n### 2024-02-22 11:15:33.046\nCREATE TABLE test\u0017( stamp DateTime('UTC'))\u0017ENGINE = MergeTree\u0017PARTITION BY toDate(stamp)\u0017order by tuple() as select toDateTime('2020-01-01')+number*60 from numbers(80000);\n\"#\n        .as_bytes()\n        .to_owned();\n\n        let replxx = Replxx { bytes };\n\n        let mut loader = TestLoader::default();\n        replxx.load(&mut loader).await.unwrap();\n        let mut history = loader.buf.into_iter();\n\n        // simple wrapper for replxx history entry\n        macro_rules! history {\n            ($timestamp:expr_2021, $command:expr_2021) => {\n                let h = history.next().expect(\"missing entry in history\");\n                assert_eq!(h.command.as_str(), $command);\n                assert_eq!(h.timestamp.unix_timestamp(), $timestamp);\n            };\n        }\n\n        history!(\n            1707603388,\n            \"select * from remote('127.0.0.1:20222', view(select 1))\"\n        );\n        history!(1707603396, \"select * from numbers(10)\");\n        history!(1707603401, \"select * from system.numbers\");\n        history!(1707603568, \"select 1\");\n        history!(\n            1708600533,\n            \"CREATE TABLE test\\n( stamp DateTime('UTC'))\\nENGINE = MergeTree\\nPARTITION BY toDate(stamp)\\norder by tuple() as select toDateTime('2020-01-01')+number*60 from numbers(80000);\"\n        );\n    }\n}\n"
  },
  {
    "path": "crates/atuin-client/src/import/resh.rs",
    "content": "use std::path::PathBuf;\n\nuse async_trait::async_trait;\nuse directories::UserDirs;\nuse eyre::{Result, eyre};\nuse serde::Deserialize;\n\nuse atuin_common::utils::uuid_v7;\nuse time::OffsetDateTime;\n\nuse super::{Importer, Loader, get_histfile_path, unix_byte_lines};\nuse crate::history::History;\nuse crate::import::read_to_end;\n\n#[derive(Deserialize, Debug)]\n#[serde(rename_all = \"camelCase\")]\npub struct ReshEntry {\n    pub cmd_line: String,\n    pub exit_code: i64,\n    pub shell: String,\n    pub uname: String,\n    pub session_id: String,\n    pub home: String,\n    pub lang: String,\n    pub lc_all: String,\n    pub login: String,\n    pub pwd: String,\n    pub pwd_after: String,\n    pub shell_env: String,\n    pub term: String,\n    pub real_pwd: String,\n    pub real_pwd_after: String,\n    pub pid: i64,\n    pub session_pid: i64,\n    pub host: String,\n    pub hosttype: String,\n    pub ostype: String,\n    pub machtype: String,\n    pub shlvl: i64,\n    pub timezone_before: String,\n    pub timezone_after: String,\n    pub realtime_before: f64,\n    pub realtime_after: f64,\n    pub realtime_before_local: f64,\n    pub realtime_after_local: f64,\n    pub realtime_duration: f64,\n    pub realtime_since_session_start: f64,\n    pub realtime_since_boot: f64,\n    pub git_dir: String,\n    pub git_real_dir: String,\n    pub git_origin_remote: String,\n    pub git_dir_after: String,\n    pub git_real_dir_after: String,\n    pub git_origin_remote_after: String,\n    pub machine_id: String,\n    pub os_release_id: String,\n    pub os_release_version_id: String,\n    pub os_release_id_like: String,\n    pub os_release_name: String,\n    pub os_release_pretty_name: String,\n    pub resh_uuid: String,\n    pub resh_version: String,\n    pub resh_revision: String,\n    pub parts_merged: bool,\n    pub recalled: bool,\n    pub recall_last_cmd_line: String,\n    pub cols: String,\n    pub lines: String,\n}\n\n#[derive(Debug)]\npub struct Resh {\n    bytes: Vec<u8>,\n}\n\nfn default_histpath() -> Result<PathBuf> {\n    let user_dirs = UserDirs::new().ok_or_else(|| eyre!(\"could not find user directories\"))?;\n    let home_dir = user_dirs.home_dir();\n\n    Ok(home_dir.join(\".resh_history.json\"))\n}\n\n#[async_trait]\nimpl Importer for Resh {\n    const NAME: &'static str = \"resh\";\n\n    async fn new() -> Result<Self> {\n        let bytes = read_to_end(get_histfile_path(default_histpath)?)?;\n        Ok(Self { bytes })\n    }\n\n    async fn entries(&mut self) -> Result<usize> {\n        Ok(super::count_lines(&self.bytes))\n    }\n\n    async fn load(self, h: &mut impl Loader) -> Result<()> {\n        for b in unix_byte_lines(&self.bytes) {\n            let s = match std::str::from_utf8(b) {\n                Ok(s) => s,\n                Err(_) => continue, // we can skip past things like invalid utf8\n            };\n            let entry = match serde_json::from_str::<ReshEntry>(s) {\n                Ok(e) => e,\n                Err(_) => continue, // skip invalid json :shrug:\n            };\n\n            #[allow(clippy::cast_possible_truncation)]\n            #[allow(clippy::cast_sign_loss)]\n            let timestamp = {\n                let secs = entry.realtime_before.floor() as i64;\n                let nanosecs = (entry.realtime_before.fract() * 1_000_000_000_f64).round() as i64;\n                OffsetDateTime::from_unix_timestamp(secs)? + time::Duration::nanoseconds(nanosecs)\n            };\n            #[allow(clippy::cast_possible_truncation)]\n            #[allow(clippy::cast_sign_loss)]\n            let duration = {\n                let secs = entry.realtime_after.floor() as i64;\n                let nanosecs = (entry.realtime_after.fract() * 1_000_000_000_f64).round() as i64;\n                let base = OffsetDateTime::from_unix_timestamp(secs)?\n                    + time::Duration::nanoseconds(nanosecs);\n                let difference = base - timestamp;\n                difference.whole_nanoseconds() as i64\n            };\n\n            let imported = History::import()\n                .command(entry.cmd_line)\n                .timestamp(timestamp)\n                .duration(duration)\n                .exit(entry.exit_code)\n                .cwd(entry.pwd)\n                .hostname(entry.host)\n                // CHECK: should we add uuid here? It's not set in the other importers\n                .session(uuid_v7().as_simple().to_string());\n\n            h.push(imported.build().into()).await?;\n        }\n\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "crates/atuin-client/src/import/xonsh.rs",
    "content": "use std::env;\nuse std::fs::{self, File};\nuse std::path::{Path, PathBuf};\n\nuse async_trait::async_trait;\nuse directories::BaseDirs;\nuse eyre::{Result, eyre};\nuse serde::Deserialize;\nuse time::OffsetDateTime;\nuse uuid::Uuid;\nuse uuid::timestamp::{Timestamp, context::NoContext};\n\nuse super::{Importer, Loader, get_histdir_path};\nuse crate::history::History;\nuse crate::utils::get_host_user;\n\n// Note: both HistoryFile and HistoryData have other keys present in the JSON, we don't\n// care about them so we leave them unspecified so as to avoid deserializing unnecessarily.\n#[derive(Debug, Deserialize)]\nstruct HistoryFile {\n    data: HistoryData,\n}\n\n#[derive(Debug, Deserialize)]\nstruct HistoryData {\n    sessionid: String,\n    cmds: Vec<HistoryCmd>,\n}\n\n#[derive(Debug, Deserialize)]\nstruct HistoryCmd {\n    cwd: String,\n    inp: String,\n    rtn: Option<i64>,\n    ts: (f64, f64),\n}\n\n#[derive(Debug)]\npub struct Xonsh {\n    // history is stored as a bunch of json files, one per session\n    sessions: Vec<HistoryData>,\n    hostname: String,\n}\n\nfn xonsh_hist_dir(xonsh_data_dir: Option<String>) -> Result<PathBuf> {\n    // if running within xonsh, this will be available\n    if let Some(d) = xonsh_data_dir {\n        let mut path = PathBuf::from(d);\n        path.push(\"history_json\");\n        return Ok(path);\n    }\n\n    // otherwise, fall back to default\n    let base = BaseDirs::new().ok_or_else(|| eyre!(\"Could not determine home directory\"))?;\n\n    let hist_dir = base.data_dir().join(\"xonsh/history_json\");\n    if hist_dir.exists() || cfg!(test) {\n        Ok(hist_dir)\n    } else {\n        Err(eyre!(\"Could not find xonsh history files\"))\n    }\n}\n\nfn load_sessions(hist_dir: &Path) -> Result<Vec<HistoryData>> {\n    let mut sessions = vec![];\n    for entry in fs::read_dir(hist_dir)? {\n        let p = entry?.path();\n        let ext = p.extension().and_then(|e| e.to_str());\n        if p.is_file()\n            && ext == Some(\"json\")\n            && let Some(data) = load_session(&p)?\n        {\n            sessions.push(data);\n        }\n    }\n    Ok(sessions)\n}\n\nfn load_session(path: &Path) -> Result<Option<HistoryData>> {\n    let file = File::open(path)?;\n    // empty files are not valid json, so we can't deserialize them\n    if file.metadata()?.len() == 0 {\n        return Ok(None);\n    }\n\n    let mut hist_file: HistoryFile = serde_json::from_reader(file)?;\n\n    // if there are commands in this session, replace the existing UUIDv4\n    // with a UUIDv7 generated from the timestamp of the first command\n    if let Some(cmd) = hist_file.data.cmds.first() {\n        let seconds = cmd.ts.0.trunc() as u64;\n        let nanos = (cmd.ts.0.fract() * 1_000_000_000_f64) as u32;\n        let ts = Timestamp::from_unix(NoContext, seconds, nanos);\n        hist_file.data.sessionid = Uuid::new_v7(ts).to_string();\n    }\n    Ok(Some(hist_file.data))\n}\n\n#[async_trait]\nimpl Importer for Xonsh {\n    const NAME: &'static str = \"xonsh\";\n\n    async fn new() -> Result<Self> {\n        // wrap xonsh-specific path resolver in general one so that it respects $HISTPATH\n        let xonsh_data_dir = env::var(\"XONSH_DATA_DIR\").ok();\n        let hist_dir = get_histdir_path(|| xonsh_hist_dir(xonsh_data_dir))?;\n        let sessions = load_sessions(&hist_dir)?;\n        let hostname = get_host_user();\n        Ok(Xonsh { sessions, hostname })\n    }\n\n    async fn entries(&mut self) -> Result<usize> {\n        let total = self.sessions.iter().map(|s| s.cmds.len()).sum();\n        Ok(total)\n    }\n\n    async fn load(self, loader: &mut impl Loader) -> Result<()> {\n        for session in self.sessions {\n            for cmd in session.cmds {\n                let (start, end) = cmd.ts;\n                let ts_nanos = (start * 1_000_000_000_f64) as i128;\n                let timestamp = OffsetDateTime::from_unix_timestamp_nanos(ts_nanos)?;\n\n                let duration = (end - start) * 1_000_000_000_f64;\n\n                match cmd.rtn {\n                    Some(exit) => {\n                        let entry = History::import()\n                            .timestamp(timestamp)\n                            .duration(duration.trunc() as i64)\n                            .exit(exit)\n                            .command(cmd.inp.trim())\n                            .cwd(cmd.cwd)\n                            .session(session.sessionid.clone())\n                            .hostname(self.hostname.clone());\n                        loader.push(entry.build().into()).await?;\n                    }\n                    None => {\n                        let entry = History::import()\n                            .timestamp(timestamp)\n                            .duration(duration.trunc() as i64)\n                            .command(cmd.inp.trim())\n                            .cwd(cmd.cwd)\n                            .session(session.sessionid.clone())\n                            .hostname(self.hostname.clone());\n                        loader.push(entry.build().into()).await?;\n                    }\n                }\n            }\n        }\n        Ok(())\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use time::macros::datetime;\n\n    use super::*;\n\n    use crate::history::History;\n    use crate::import::tests::TestLoader;\n\n    #[test]\n    fn test_hist_dir_xonsh() {\n        let hist_dir = xonsh_hist_dir(Some(\"/home/user/xonsh_data\".to_string())).unwrap();\n        assert_eq!(\n            hist_dir,\n            PathBuf::from(\"/home/user/xonsh_data/history_json\")\n        );\n    }\n\n    #[tokio::test]\n    async fn test_import() {\n        let dir = PathBuf::from(\"tests/data/xonsh\");\n        let sessions = load_sessions(&dir).unwrap();\n        let hostname = \"box:user\".to_string();\n        let xonsh = Xonsh { sessions, hostname };\n\n        let mut loader = TestLoader::default();\n        xonsh.load(&mut loader).await.unwrap();\n        // order in buf will depend on filenames, so sort by timestamp for consistency\n        loader.buf.sort_by_key(|h| h.timestamp);\n        for (actual, expected) in loader.buf.iter().zip(expected_hist_entries().iter()) {\n            assert_eq!(actual.timestamp, expected.timestamp);\n            assert_eq!(actual.command, expected.command);\n            assert_eq!(actual.cwd, expected.cwd);\n            assert_eq!(actual.exit, expected.exit);\n            assert_eq!(actual.duration, expected.duration);\n            assert_eq!(actual.hostname, expected.hostname);\n        }\n    }\n\n    fn expected_hist_entries() -> [History; 4] {\n        [\n            History::import()\n                .timestamp(datetime!(2024-02-6 04:17:59.478272256 +00:00:00))\n                .command(\"echo hello world!\".to_string())\n                .cwd(\"/home/user/Documents/code/atuin\".to_string())\n                .exit(0)\n                .duration(4651069)\n                .hostname(\"box:user\".to_string())\n                .build()\n                .into(),\n            History::import()\n                .timestamp(datetime!(2024-02-06 04:18:01.70632832 +00:00:00))\n                .command(\"ls -l\".to_string())\n                .cwd(\"/home/user/Documents/code/atuin\".to_string())\n                .exit(0)\n                .duration(21288633)\n                .hostname(\"box:user\".to_string())\n                .build()\n                .into(),\n            History::import()\n                .timestamp(datetime!(2024-02-06 17:41:31.142515968 +00:00:00))\n                .command(\"false\".to_string())\n                .cwd(\"/home/user/Documents/code/atuin/atuin-client\".to_string())\n                .exit(1)\n                .duration(10269403)\n                .hostname(\"box:user\".to_string())\n                .build()\n                .into(),\n            History::import()\n                .timestamp(datetime!(2024-02-06 17:41:32.271584 +00:00:00))\n                .command(\"exit\".to_string())\n                .cwd(\"/home/user/Documents/code/atuin/atuin-client\".to_string())\n                .exit(0)\n                .duration(4259347)\n                .hostname(\"box:user\".to_string())\n                .build()\n                .into(),\n        ]\n    }\n}\n"
  },
  {
    "path": "crates/atuin-client/src/import/xonsh_sqlite.rs",
    "content": "use std::env;\nuse std::path::PathBuf;\n\nuse async_trait::async_trait;\nuse directories::BaseDirs;\nuse eyre::{Result, eyre};\nuse futures::TryStreamExt;\nuse sqlx::{FromRow, Row, sqlite::SqlitePool};\nuse time::OffsetDateTime;\nuse uuid::Uuid;\nuse uuid::timestamp::{Timestamp, context::NoContext};\n\nuse super::{Importer, Loader, get_histfile_path};\nuse crate::history::History;\nuse crate::utils::get_host_user;\n\n#[derive(Debug, FromRow)]\nstruct HistDbEntry {\n    inp: String,\n    rtn: Option<i64>,\n    tsb: f64,\n    tse: f64,\n    cwd: String,\n    session_start: f64,\n}\n\nimpl HistDbEntry {\n    fn into_hist_with_hostname(self, hostname: String) -> History {\n        let ts_nanos = (self.tsb * 1_000_000_000_f64) as i128;\n        let timestamp = OffsetDateTime::from_unix_timestamp_nanos(ts_nanos).unwrap();\n\n        let session_ts_seconds = self.session_start.trunc() as u64;\n        let session_ts_nanos = (self.session_start.fract() * 1_000_000_000_f64) as u32;\n        let session_ts = Timestamp::from_unix(NoContext, session_ts_seconds, session_ts_nanos);\n        let session_id = Uuid::new_v7(session_ts).to_string();\n        let duration = (self.tse - self.tsb) * 1_000_000_000_f64;\n\n        if let Some(exit) = self.rtn {\n            let imported = History::import()\n                .timestamp(timestamp)\n                .duration(duration.trunc() as i64)\n                .exit(exit)\n                .command(self.inp)\n                .cwd(self.cwd)\n                .session(session_id)\n                .hostname(hostname);\n            imported.build().into()\n        } else {\n            let imported = History::import()\n                .timestamp(timestamp)\n                .duration(duration.trunc() as i64)\n                .command(self.inp)\n                .cwd(self.cwd)\n                .session(session_id)\n                .hostname(hostname);\n            imported.build().into()\n        }\n    }\n}\n\nfn xonsh_db_path(xonsh_data_dir: Option<String>) -> Result<PathBuf> {\n    // if running within xonsh, this will be available\n    if let Some(d) = xonsh_data_dir {\n        let mut path = PathBuf::from(d);\n        path.push(\"xonsh-history.sqlite\");\n        return Ok(path);\n    }\n\n    // otherwise, fall back to default\n    let base = BaseDirs::new().ok_or_else(|| eyre!(\"Could not determine home directory\"))?;\n\n    let hist_file = base.data_dir().join(\"xonsh/xonsh-history.sqlite\");\n    if hist_file.exists() || cfg!(test) {\n        Ok(hist_file)\n    } else {\n        Err(eyre!(\n            \"Could not find xonsh history db at: {}\",\n            hist_file.to_string_lossy()\n        ))\n    }\n}\n\n#[derive(Debug)]\npub struct XonshSqlite {\n    pool: SqlitePool,\n    hostname: String,\n}\n\n#[async_trait]\nimpl Importer for XonshSqlite {\n    const NAME: &'static str = \"xonsh_sqlite\";\n\n    async fn new() -> Result<Self> {\n        // wrap xonsh-specific path resolver in general one so that it respects $HISTPATH\n        let xonsh_data_dir = env::var(\"XONSH_DATA_DIR\").ok();\n        let db_path = get_histfile_path(|| xonsh_db_path(xonsh_data_dir))?;\n        let connection_str = db_path.to_str().ok_or_else(|| {\n            eyre!(\n                \"Invalid path for SQLite database: {}\",\n                db_path.to_string_lossy()\n            )\n        })?;\n\n        let pool = SqlitePool::connect(connection_str).await?;\n        let hostname = get_host_user();\n        Ok(XonshSqlite { pool, hostname })\n    }\n\n    async fn entries(&mut self) -> Result<usize> {\n        let query = \"SELECT COUNT(*) FROM xonsh_history\";\n        let row = sqlx::query(query).fetch_one(&self.pool).await?;\n        let count: u32 = row.get(0);\n        Ok(count as usize)\n    }\n\n    async fn load(self, loader: &mut impl Loader) -> Result<()> {\n        let query = r#\"\n            SELECT inp, rtn, tsb, tse, cwd,\n            MIN(tsb) OVER (PARTITION BY sessionid) AS session_start\n            FROM xonsh_history\n            ORDER BY rowid\n        \"#;\n\n        let mut entries = sqlx::query_as::<_, HistDbEntry>(query).fetch(&self.pool);\n\n        let mut count = 0;\n        while let Some(entry) = entries.try_next().await? {\n            let hist = entry.into_hist_with_hostname(self.hostname.clone());\n            loader.push(hist).await?;\n            count += 1;\n        }\n\n        println!(\"Loaded: {count}\");\n        Ok(())\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use time::macros::datetime;\n\n    use super::*;\n\n    use crate::history::History;\n    use crate::import::tests::TestLoader;\n\n    #[test]\n    fn test_db_path_xonsh() {\n        let db_path = xonsh_db_path(Some(\"/home/user/xonsh_data\".to_string())).unwrap();\n        assert_eq!(\n            db_path,\n            PathBuf::from(\"/home/user/xonsh_data/xonsh-history.sqlite\")\n        );\n    }\n\n    #[tokio::test]\n    async fn test_import() {\n        let connection_str = \"tests/data/xonsh-history.sqlite\";\n        let xonsh_sqlite = XonshSqlite {\n            pool: SqlitePool::connect(connection_str).await.unwrap(),\n            hostname: \"box:user\".to_string(),\n        };\n\n        let mut loader = TestLoader::default();\n        xonsh_sqlite.load(&mut loader).await.unwrap();\n\n        for (actual, expected) in loader.buf.iter().zip(expected_hist_entries().iter()) {\n            assert_eq!(actual.timestamp, expected.timestamp);\n            assert_eq!(actual.command, expected.command);\n            assert_eq!(actual.cwd, expected.cwd);\n            assert_eq!(actual.exit, expected.exit);\n            assert_eq!(actual.duration, expected.duration);\n            assert_eq!(actual.hostname, expected.hostname);\n        }\n    }\n\n    fn expected_hist_entries() -> [History; 4] {\n        [\n            History::import()\n                .timestamp(datetime!(2024-02-6 17:56:21.130956288 +00:00:00))\n                .command(\"echo hello world!\".to_string())\n                .cwd(\"/home/user/Documents/code/atuin\".to_string())\n                .exit(0)\n                .duration(2628564)\n                .hostname(\"box:user\".to_string())\n                .build()\n                .into(),\n            History::import()\n                .timestamp(datetime!(2024-02-06 17:56:28.190406144 +00:00:00))\n                .command(\"ls -l\".to_string())\n                .cwd(\"/home/user/Documents/code/atuin\".to_string())\n                .exit(0)\n                .duration(9371519)\n                .hostname(\"box:user\".to_string())\n                .build()\n                .into(),\n            History::import()\n                .timestamp(datetime!(2024-02-06 17:56:46.989020928 +00:00:00))\n                .command(\"false\".to_string())\n                .cwd(\"/home/user/Documents/code/atuin\".to_string())\n                .exit(1)\n                .duration(17337560)\n                .hostname(\"box:user\".to_string())\n                .build()\n                .into(),\n            History::import()\n                .timestamp(datetime!(2024-02-06 17:56:48.218384128 +00:00:00))\n                .command(\"exit\".to_string())\n                .cwd(\"/home/user/Documents/code/atuin\".to_string())\n                .exit(0)\n                .duration(4599094)\n                .hostname(\"box:user\".to_string())\n                .build()\n                .into(),\n        ]\n    }\n}\n"
  },
  {
    "path": "crates/atuin-client/src/import/zsh.rs",
    "content": "// import old shell history!\n// automatically hoover up all that we can find\n\nuse std::borrow::Cow;\nuse std::path::PathBuf;\n\nuse async_trait::async_trait;\nuse directories::UserDirs;\nuse eyre::{Result, eyre};\nuse time::OffsetDateTime;\n\nuse super::{Importer, Loader, get_histfile_path, unix_byte_lines};\nuse crate::history::History;\nuse crate::import::read_to_end;\n\n#[derive(Debug)]\npub struct Zsh {\n    bytes: Vec<u8>,\n}\n\nfn default_histpath() -> Result<PathBuf> {\n    // oh-my-zsh sets HISTFILE=~/.zhistory\n    // zsh has no default value for this var, but uses ~/.zhistory.\n    // zsh-newuser-install propose as default .histfile https://github.com/zsh-users/zsh/blob/master/Functions/Newuser/zsh-newuser-install#L794\n    // we could maybe be smarter about this in the future :)\n    let user_dirs = UserDirs::new().ok_or_else(|| eyre!(\"could not find user directories\"))?;\n    let home_dir = user_dirs.home_dir();\n\n    let mut candidates = [\".zhistory\", \".zsh_history\", \".histfile\"].iter();\n    loop {\n        match candidates.next() {\n            Some(candidate) => {\n                let histpath = home_dir.join(candidate);\n                if histpath.exists() {\n                    break Ok(histpath);\n                }\n            }\n            None => {\n                break Err(eyre!(\n                    \"Could not find history file. Try setting and exporting $HISTFILE\"\n                ));\n            }\n        }\n    }\n}\n\n#[async_trait]\nimpl Importer for Zsh {\n    const NAME: &'static str = \"zsh\";\n\n    async fn new() -> Result<Self> {\n        let bytes = read_to_end(get_histfile_path(default_histpath)?)?;\n        Ok(Self { bytes })\n    }\n\n    async fn entries(&mut self) -> Result<usize> {\n        Ok(super::count_lines(&self.bytes))\n    }\n\n    async fn load(self, h: &mut impl Loader) -> Result<()> {\n        let now = OffsetDateTime::now_utc();\n        let mut line = String::new();\n\n        let mut counter = 0;\n        for b in unix_byte_lines(&self.bytes) {\n            let s = match unmetafy(b) {\n                Some(s) => s,\n                _ => continue, // we can skip past things like invalid utf8\n            };\n\n            if let Some(s) = s.strip_suffix('\\\\') {\n                line.push_str(s);\n                line.push('\\n');\n            } else {\n                line.push_str(&s);\n                let command = std::mem::take(&mut line);\n\n                if let Some(command) = command.strip_prefix(\": \") {\n                    counter += 1;\n                    h.push(parse_extended(command, counter)).await?;\n                } else {\n                    let offset = time::Duration::seconds(counter);\n                    counter += 1;\n\n                    let imported = History::import()\n                        // preserve ordering\n                        .timestamp(now - offset)\n                        .command(command.trim_end().to_string());\n\n                    h.push(imported.build().into()).await?;\n                }\n            }\n        }\n\n        Ok(())\n    }\n}\n\nfn parse_extended(line: &str, counter: i64) -> History {\n    let (time, duration) = line.split_once(':').unwrap();\n    let (duration, command) = duration.split_once(';').unwrap();\n\n    let time = time\n        .parse::<i64>()\n        .ok()\n        .and_then(|t| OffsetDateTime::from_unix_timestamp(t).ok())\n        .unwrap_or_else(OffsetDateTime::now_utc)\n        + time::Duration::milliseconds(counter);\n\n    // use nanos, because why the hell not? we won't display them.\n    let duration = duration.parse::<i64>().map_or(-1, |t| t * 1_000_000_000);\n\n    let imported = History::import()\n        .timestamp(time)\n        .command(command.trim_end().to_string())\n        .duration(duration);\n\n    imported.build().into()\n}\n\nfn unmetafy(line: &[u8]) -> Option<Cow<'_, str>> {\n    if line.contains(&0x83) {\n        let mut s = Vec::with_capacity(line.len());\n        let mut is_meta = false;\n        for ch in line {\n            if *ch == 0x83 {\n                is_meta = true;\n            } else if is_meta {\n                is_meta = false;\n                s.push(*ch ^ 32);\n            } else {\n                s.push(*ch)\n            }\n        }\n        String::from_utf8(s).ok().map(Cow::Owned)\n    } else {\n        std::str::from_utf8(line).ok().map(Cow::Borrowed)\n    }\n}\n\n#[cfg(test)]\nmod test {\n    use itertools::assert_equal;\n\n    use crate::import::tests::TestLoader;\n\n    use super::*;\n\n    #[test]\n    fn test_parse_extended_simple() {\n        let parsed = parse_extended(\"1613322469:0;cargo install atuin\", 0);\n\n        assert_eq!(parsed.command, \"cargo install atuin\");\n        assert_eq!(parsed.duration, 0);\n        assert_eq!(\n            parsed.timestamp,\n            OffsetDateTime::from_unix_timestamp(1_613_322_469).unwrap()\n        );\n\n        let parsed = parse_extended(\"1613322469:10;cargo install atuin;cargo update\", 0);\n\n        assert_eq!(parsed.command, \"cargo install atuin;cargo update\");\n        assert_eq!(parsed.duration, 10_000_000_000);\n        assert_eq!(\n            parsed.timestamp,\n            OffsetDateTime::from_unix_timestamp(1_613_322_469).unwrap()\n        );\n\n        let parsed = parse_extended(\"1613322469:10;cargo :b̷i̶t̴r̵o̴t̴ ̵i̷s̴ ̷r̶e̵a̸l̷\", 0);\n\n        assert_eq!(parsed.command, \"cargo :b̷i̶t̴r̵o̴t̴ ̵i̷s̴ ̷r̶e̵a̸l̷\");\n        assert_eq!(parsed.duration, 10_000_000_000);\n        assert_eq!(\n            parsed.timestamp,\n            OffsetDateTime::from_unix_timestamp(1_613_322_469).unwrap()\n        );\n\n        let parsed = parse_extended(\"1613322469:10;cargo install \\\\n atuin\\n\", 0);\n\n        assert_eq!(parsed.command, \"cargo install \\\\n atuin\");\n        assert_eq!(parsed.duration, 10_000_000_000);\n        assert_eq!(\n            parsed.timestamp,\n            OffsetDateTime::from_unix_timestamp(1_613_322_469).unwrap()\n        );\n    }\n\n    #[tokio::test]\n    async fn test_parse_file() {\n        let bytes = r\": 1613322469:0;cargo install atuin\n: 1613322469:10;cargo install atuin; \\\\\ncargo update\n: 1613322469:10;cargo :b̷i̶t̴r̵o̴t̴ ̵i̷s̴ ̷r̶e̵a̸l̷\n\"\n        .as_bytes()\n        .to_owned();\n\n        let mut zsh = Zsh { bytes };\n        assert_eq!(zsh.entries().await.unwrap(), 4);\n\n        let mut loader = TestLoader::default();\n        zsh.load(&mut loader).await.unwrap();\n\n        assert_equal(\n            loader.buf.iter().map(|h| h.command.as_str()),\n            [\n                \"cargo install atuin\",\n                \"cargo install atuin; \\\\\\ncargo update\",\n                \"cargo :b̷i̶t̴r̵o̴t̴ ̵i̷s̴ ̷r̶e̵a̸l̷\",\n            ],\n        );\n    }\n\n    #[tokio::test]\n    async fn test_parse_metafied() {\n        let bytes =\n            b\"echo \\xe4\\xbd\\x83\\x80\\xe5\\xa5\\xbd\\nls ~/\\xe9\\x83\\xbf\\xb3\\xe4\\xb9\\x83\\xb0\\n\".to_vec();\n\n        let mut zsh = Zsh { bytes };\n        assert_eq!(zsh.entries().await.unwrap(), 2);\n\n        let mut loader = TestLoader::default();\n        zsh.load(&mut loader).await.unwrap();\n\n        assert_equal(\n            loader.buf.iter().map(|h| h.command.as_str()),\n            [\"echo 你好\", \"ls ~/音乐\"],\n        );\n    }\n}\n"
  },
  {
    "path": "crates/atuin-client/src/import/zsh_histdb.rs",
    "content": "// import old shell history from zsh-histdb!\n// automatically hoover up all that we can find\n\n// As far as i can tell there are no version numbers in the histdb sqlite DB, so we're going based\n// on the schema from 2022-05-01\n//\n// I have run into some histories that will not import b/c of non UTF-8 characters.\n//\n\n//\n// An Example sqlite query for hsitdb data:\n//\n//id|session|command_id|place_id|exit_status|start_time|duration|id|argv|id|host|dir\n//\n//\n//  select\n//    history.id,\n//    history.start_time,\n//    places.host,\n//    places.dir,\n//    commands.argv\n//  from history\n//    left join commands on history.command_id = commands.id\n//    left join places on history.place_id = places.id ;\n//\n// CREATE TABLE history  (id integer primary key autoincrement,\n//                       session int,\n//                       command_id int references commands (id),\n//                       place_id int references places (id),\n//                       exit_status int,\n//                       start_time int,\n//                       duration int);\n//\n\nuse std::collections::HashMap;\nuse std::path::{Path, PathBuf};\n\nuse async_trait::async_trait;\nuse atuin_common::utils::uuid_v7;\nuse directories::UserDirs;\nuse eyre::{Result, eyre};\nuse sqlx::{Pool, sqlite::SqlitePool};\nuse time::PrimitiveDateTime;\n\nuse super::Importer;\nuse crate::history::History;\nuse crate::import::Loader;\nuse crate::utils::{get_hostname, get_username};\n\n#[derive(sqlx::FromRow, Debug)]\npub struct HistDbEntryCount {\n    pub count: usize,\n}\n\n#[derive(sqlx::FromRow, Debug)]\npub struct HistDbEntry {\n    pub id: i64,\n    pub start_time: PrimitiveDateTime,\n    pub host: Vec<u8>,\n    pub dir: Vec<u8>,\n    pub argv: Vec<u8>,\n    pub duration: i64,\n    pub exit_status: i64,\n    pub session: i64,\n}\n\n#[derive(Debug)]\npub struct ZshHistDb {\n    histdb: Vec<HistDbEntry>,\n    username: String,\n}\n\n/// Read db at given file, return vector of entries.\nasync fn hist_from_db(dbpath: PathBuf) -> Result<Vec<HistDbEntry>> {\n    let pool = SqlitePool::connect(dbpath.to_str().unwrap()).await?;\n    hist_from_db_conn(pool).await\n}\n\nasync fn hist_from_db_conn(pool: Pool<sqlx::Sqlite>) -> Result<Vec<HistDbEntry>> {\n    let query = r#\"\n        SELECT\n            history.id, history.start_time, history.duration, places.host, places.dir,\n            commands.argv, history.exit_status, history.session\n        FROM history\n        LEFT JOIN commands ON history.command_id = commands.id\n        LEFT JOIN places ON history.place_id = places.id\n        ORDER BY history.start_time\n    \"#;\n    let histdb_vec: Vec<HistDbEntry> = sqlx::query_as::<_, HistDbEntry>(query)\n        .fetch_all(&pool)\n        .await?;\n    Ok(histdb_vec)\n}\n\nimpl ZshHistDb {\n    pub fn histpath_candidate() -> PathBuf {\n        // By default histdb database is `${HOME}/.histdb/zsh-history.db`\n        // This can be modified by ${HISTDB_FILE}\n        //\n        //  if [[ -z ${HISTDB_FILE} ]]; then\n        //      typeset -g HISTDB_FILE=\"${HOME}/.histdb/zsh-history.db\"\n        let user_dirs = UserDirs::new().unwrap(); // should catch error here?\n        let home_dir = user_dirs.home_dir();\n        std::env::var(\"HISTDB_FILE\")\n            .as_ref()\n            .map(|x| Path::new(x).to_path_buf())\n            .unwrap_or_else(|_err| home_dir.join(\".histdb/zsh-history.db\"))\n    }\n    pub fn histpath() -> Result<PathBuf> {\n        let histdb_path = ZshHistDb::histpath_candidate();\n        if histdb_path.exists() {\n            Ok(histdb_path)\n        } else {\n            Err(eyre!(\n                \"Could not find history file. Try setting $HISTDB_FILE\"\n            ))\n        }\n    }\n}\n\n#[async_trait]\nimpl Importer for ZshHistDb {\n    // Not sure how this is used\n    const NAME: &'static str = \"zsh_histdb\";\n\n    /// Creates a new ZshHistDb and populates the history based on the pre-populated data\n    /// structure.\n    async fn new() -> Result<Self> {\n        let dbpath = ZshHistDb::histpath()?;\n        let histdb_entry_vec = hist_from_db(dbpath).await?;\n        Ok(Self {\n            histdb: histdb_entry_vec,\n            username: get_username(),\n        })\n    }\n\n    async fn entries(&mut self) -> Result<usize> {\n        Ok(self.histdb.len())\n    }\n\n    async fn load(self, h: &mut impl Loader) -> Result<()> {\n        let mut session_map = HashMap::new();\n        for entry in self.histdb {\n            let command = match std::str::from_utf8(&entry.argv) {\n                Ok(s) => s.trim_end(),\n                Err(_) => continue, // we can skip past things like invalid utf8\n            };\n            let cwd = match std::str::from_utf8(&entry.dir) {\n                Ok(s) => s.trim_end(),\n                Err(_) => continue, // we can skip past things like invalid utf8\n            };\n            let hostname = format!(\n                \"{}:{}\",\n                String::from_utf8(entry.host).unwrap_or_else(|_e| get_hostname()),\n                self.username\n            );\n            let session = session_map.entry(entry.session).or_insert_with(uuid_v7);\n\n            let imported = History::import()\n                .timestamp(entry.start_time.assume_utc())\n                .command(command)\n                .cwd(cwd)\n                .duration(entry.duration * 1_000_000_000)\n                .exit(entry.exit_status)\n                .session(session.as_simple().to_string())\n                .hostname(hostname)\n                .build();\n            h.push(imported.into()).await?;\n        }\n        Ok(())\n    }\n}\n\n#[cfg(test)]\nmod test {\n\n    use super::*;\n    use sqlx::sqlite::SqlitePoolOptions;\n    use std::env;\n    #[tokio::test(flavor = \"multi_thread\")]\n    #[allow(unsafe_code)]\n    async fn test_env_vars() {\n        let test_env_db = \"nonstd-zsh-history.db\";\n        let key = \"HISTDB_FILE\";\n        // TODO: Audit that the environment access only happens in single-threaded code.\n        unsafe { env::set_var(key, test_env_db) };\n\n        // test the env got set\n        assert_eq!(env::var(key).unwrap(), test_env_db.to_string());\n\n        // test histdb returns the proper db from previous step\n        let histdb_path = ZshHistDb::histpath_candidate();\n        assert_eq!(histdb_path.to_str().unwrap(), test_env_db);\n    }\n\n    #[tokio::test(flavor = \"multi_thread\")]\n    async fn test_import() {\n        let pool: SqlitePool = SqlitePoolOptions::new()\n            .min_connections(2)\n            .connect(\":memory:\")\n            .await\n            .unwrap();\n\n        // sql dump directly from a test database.\n        let db_sql = r#\"\n        PRAGMA foreign_keys=OFF;\n        BEGIN TRANSACTION;\n        CREATE TABLE commands (id integer primary key autoincrement, argv text, unique(argv) on conflict ignore);\n        INSERT INTO commands VALUES(1,'pwd');\n        INSERT INTO commands VALUES(2,'curl google.com');\n        INSERT INTO commands VALUES(3,'bash');\n        CREATE TABLE places   (id integer primary key autoincrement, host text, dir text, unique(host, dir) on conflict ignore);\n        INSERT INTO places VALUES(1,'mbp16.local','/home/noyez');\n        CREATE TABLE history  (id integer primary key autoincrement,\n                               session int,\n                               command_id int references commands (id),\n                               place_id int references places (id),\n                               exit_status int,\n                               start_time int,\n                               duration int);\n        INSERT INTO history VALUES(1,0,1,1,0,1651497918,1);\n        INSERT INTO history VALUES(2,0,2,1,0,1651497923,1);\n        INSERT INTO history VALUES(3,0,3,1,NULL,1651497930,NULL);\n        DELETE FROM sqlite_sequence;\n        INSERT INTO sqlite_sequence VALUES('commands',3);\n        INSERT INTO sqlite_sequence VALUES('places',3);\n        INSERT INTO sqlite_sequence VALUES('history',3);\n        CREATE INDEX hist_time on history(start_time);\n        CREATE INDEX place_dir on places(dir);\n        CREATE INDEX place_host on places(host);\n        CREATE INDEX history_command_place on history(command_id, place_id);\n        COMMIT; \"#;\n\n        sqlx::query(db_sql).execute(&pool).await.unwrap();\n\n        // test histdb iterator\n        let histdb_vec = hist_from_db_conn(pool).await.unwrap();\n        let histdb = ZshHistDb {\n            histdb: histdb_vec,\n            username: get_username(),\n        };\n\n        println!(\"h: {:#?}\", histdb.histdb);\n        println!(\"counter: {:?}\", histdb.histdb.len());\n        for i in histdb.histdb {\n            println!(\"{i:?}\");\n        }\n    }\n}\n"
  },
  {
    "path": "crates/atuin-client/src/lib.rs",
    "content": "#![deny(unsafe_code)]\n\n#[macro_use]\nextern crate log;\n\n#[cfg(feature = \"sync\")]\npub mod api_client;\n#[cfg(feature = \"sync\")]\npub mod auth;\n#[cfg(feature = \"hub\")]\npub mod hub;\n#[cfg(feature = \"sync\")]\npub mod login;\n#[cfg(feature = \"sync\")]\npub mod register;\n#[cfg(feature = \"sync\")]\npub mod sync;\n\npub mod database;\npub mod distro;\npub mod encryption;\npub mod history;\npub mod import;\npub mod logout;\npub mod meta;\npub mod ordering;\npub mod plugin;\npub mod record;\npub mod secrets;\npub mod settings;\npub mod theme;\n\nmod utils;\n"
  },
  {
    "path": "crates/atuin-client/src/login.rs",
    "content": "use std::path::PathBuf;\n\nuse atuin_common::api::LoginRequest;\nuse eyre::{Context, Result, bail};\nuse tokio::fs::File;\nuse tokio::io::AsyncWriteExt;\n\nuse crate::{\n    api_client,\n    encryption::{Key, decode_key, encode_key, load_key},\n    record::{sqlite_store::SqliteStore, store::Store},\n    settings::Settings,\n};\n\npub async fn login(\n    settings: &Settings,\n    store: &SqliteStore,\n    username: String,\n    password: String,\n    key: String,\n) -> Result<String> {\n    // try parse the key as a mnemonic...\n    let key = match bip39::Mnemonic::from_phrase(&key, bip39::Language::English) {\n        Ok(mnemonic) => encode_key(Key::from_slice(mnemonic.entropy()))?,\n        Err(err) => {\n            match err {\n                // assume they copied in the base64 key\n                bip39::ErrorKind::InvalidWord(_) => key,\n                bip39::ErrorKind::InvalidChecksum => {\n                    bail!(\"key mnemonic was not valid\")\n                }\n                bip39::ErrorKind::InvalidKeysize(_)\n                | bip39::ErrorKind::InvalidWordLength(_)\n                | bip39::ErrorKind::InvalidEntropyLength(_, _) => {\n                    bail!(\"key was not the correct length\")\n                }\n            }\n        }\n    };\n\n    let key_path = settings.key_path.as_str();\n    let key_path = PathBuf::from(key_path);\n\n    if !key_path.exists() {\n        if decode_key(key.clone()).is_err() {\n            bail!(\"the specified key was invalid\");\n        }\n\n        let mut file = File::create(&key_path).await?;\n        file.write_all(key.as_bytes()).await?;\n    } else {\n        // we now know that the user has logged in specifying a key, AND that the key path\n        // exists\n\n        // 1. check if the saved key and the provided key match. if so, nothing to do.\n        // 2. if not, re-encrypt the local history and overwrite the key\n        let current_key: [u8; 32] = load_key(settings)?.into();\n\n        let encoded = key.clone(); // gonna want to save it in a bit\n        let new_key: [u8; 32] = decode_key(key)\n            .context(\"could not decode provided key - is not valid base64\")?\n            .into();\n\n        if new_key != current_key {\n            println!(\"\\nRe-encrypting local store with new key\");\n\n            store.re_encrypt(&current_key, &new_key).await?;\n\n            println!(\"Writing new key\");\n            let mut file = File::create(&key_path).await?;\n            file.write_all(encoded.as_bytes()).await?;\n        }\n    }\n\n    let session = api_client::login(\n        settings.sync_address.as_str(),\n        LoginRequest { username, password },\n    )\n    .await?;\n\n    Settings::meta_store()\n        .await?\n        .save_session(&session.session)\n        .await?;\n\n    Ok(session.session)\n}\n"
  },
  {
    "path": "crates/atuin-client/src/logout.rs",
    "content": "use eyre::Result;\n\nuse crate::settings::Settings;\n\npub async fn logout() -> Result<()> {\n    let meta = Settings::meta_store().await?;\n\n    if meta.logged_in().await? {\n        meta.delete_session().await?;\n        meta.delete_hub_session().await?;\n        println!(\"You have logged out!\");\n    } else {\n        println!(\"You are not logged in\");\n    }\n\n    Ok(())\n}\n"
  },
  {
    "path": "crates/atuin-client/src/meta.rs",
    "content": "use std::path::Path;\nuse std::str::FromStr;\nuse std::time::Duration;\n\nuse atuin_common::record::HostId;\nuse eyre::{Result, eyre};\nuse sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePool, SqlitePoolOptions};\nuse time::{OffsetDateTime, format_description::well_known::Rfc3339};\nuse tokio::sync::OnceCell;\nuse uuid::Uuid;\n\n// Filenames for the legacy plain-text files that we migrate from.\nconst LEGACY_HOST_ID_FILENAME: &str = \"host_id\";\nconst LEGACY_LAST_SYNC_FILENAME: &str = \"last_sync_time\";\nconst LEGACY_LAST_VERSION_CHECK_FILENAME: &str = \"last_version_check_time\";\nconst LEGACY_LATEST_VERSION_FILENAME: &str = \"latest_version\";\nconst LEGACY_SESSION_FILENAME: &str = \"session\";\n\nconst KEY_HOST_ID: &str = \"host_id\";\nconst KEY_LAST_SYNC: &str = \"last_sync_time\";\nconst KEY_LAST_VERSION_CHECK: &str = \"last_version_check_time\";\nconst KEY_LATEST_VERSION: &str = \"latest_version\";\nconst KEY_SESSION: &str = \"session\";\nconst KEY_HUB_SESSION: &str = \"hub_session\";\nconst KEY_FILES_MIGRATED: &str = \"files_migrated\";\n\npub struct MetaStore {\n    pool: SqlitePool,\n    cached_host_id: OnceCell<HostId>,\n}\n\nimpl MetaStore {\n    pub async fn new(path: impl AsRef<Path>, timeout: f64) -> Result<Self> {\n        let path = path.as_ref();\n        let path_str = path\n            .as_os_str()\n            .to_str()\n            .ok_or_else(|| eyre!(\"meta database path is not valid UTF-8: {path:?}\"))?;\n        debug!(\"opening meta sqlite database at {path:?}\");\n\n        let is_memory = path_str.contains(\":memory:\");\n\n        if !is_memory\n            && !path.exists()\n            && let Some(dir) = path.parent()\n        {\n            fs_err::create_dir_all(dir)?;\n        }\n\n        // Use DELETE journal mode instead of WAL. This is a small, infrequently-\n        // written KV store — WAL's concurrency benefits aren't needed, and DELETE\n        // mode avoids creating auxiliary -wal/-shm files that complicate\n        // permission handling.\n        let opts = SqliteConnectOptions::from_str(path_str)?\n            .journal_mode(SqliteJournalMode::Delete)\n            .optimize_on_close(true, None)\n            .create_if_missing(true);\n\n        let pool = SqlitePoolOptions::new()\n            .acquire_timeout(Duration::from_secs_f64(timeout))\n            .connect_with(opts)\n            .await?;\n\n        sqlx::migrate!(\"./meta-migrations\").run(&pool).await?;\n\n        // Session tokens are stored in this database, so restrict permissions.\n        #[cfg(unix)]\n        if !is_memory {\n            use std::os::unix::fs::PermissionsExt;\n            std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?;\n        }\n\n        let store = Self {\n            pool,\n            cached_host_id: OnceCell::const_new(),\n        };\n\n        if !is_memory {\n            store.migrate_files().await?;\n        }\n\n        Ok(store)\n    }\n\n    // Generic key-value operations\n\n    pub async fn get(&self, key: &str) -> Result<Option<String>> {\n        let row: Option<(String,)> = sqlx::query_as(\"SELECT value FROM meta WHERE key = ?1\")\n            .bind(key)\n            .fetch_optional(&self.pool)\n            .await?;\n\n        Ok(row.map(|r| r.0))\n    }\n\n    pub async fn set(&self, key: &str, value: &str) -> Result<()> {\n        sqlx::query(\n            \"INSERT INTO meta (key, value, updated_at) VALUES (?1, ?2, strftime('%s', 'now'))\n             ON CONFLICT(key) DO UPDATE SET value = ?2, updated_at = strftime('%s', 'now')\",\n        )\n        .bind(key)\n        .bind(value)\n        .execute(&self.pool)\n        .await?;\n\n        Ok(())\n    }\n\n    pub async fn delete(&self, key: &str) -> Result<()> {\n        sqlx::query(\"DELETE FROM meta WHERE key = ?1\")\n            .bind(key)\n            .execute(&self.pool)\n            .await?;\n\n        Ok(())\n    }\n\n    // Typed accessors\n\n    pub async fn host_id(&self) -> Result<HostId> {\n        self.cached_host_id\n            .get_or_try_init(|| async {\n                if let Some(id) = self.get(KEY_HOST_ID).await? {\n                    let parsed = Uuid::from_str(id.as_str())\n                        .map_err(|e| eyre!(\"failed to parse host ID: {e}\"))?;\n                    return Ok(HostId(parsed));\n                }\n\n                let uuid = atuin_common::utils::uuid_v7();\n                self.set(KEY_HOST_ID, uuid.as_simple().to_string().as_ref())\n                    .await?;\n\n                Ok(HostId(uuid))\n            })\n            .await\n            .copied()\n    }\n\n    pub async fn last_sync(&self) -> Result<OffsetDateTime> {\n        match self.get(KEY_LAST_SYNC).await? {\n            Some(v) => Ok(OffsetDateTime::parse(v.as_str(), &Rfc3339)?),\n            None => Ok(OffsetDateTime::UNIX_EPOCH),\n        }\n    }\n\n    pub async fn save_sync_time(&self) -> Result<()> {\n        self.set(\n            KEY_LAST_SYNC,\n            OffsetDateTime::now_utc().format(&Rfc3339)?.as_str(),\n        )\n        .await\n    }\n\n    pub async fn last_version_check(&self) -> Result<OffsetDateTime> {\n        match self.get(KEY_LAST_VERSION_CHECK).await? {\n            Some(v) => Ok(OffsetDateTime::parse(v.as_str(), &Rfc3339)?),\n            None => Ok(OffsetDateTime::UNIX_EPOCH),\n        }\n    }\n\n    pub async fn save_version_check_time(&self) -> Result<()> {\n        self.set(\n            KEY_LAST_VERSION_CHECK,\n            OffsetDateTime::now_utc().format(&Rfc3339)?.as_str(),\n        )\n        .await\n    }\n\n    pub async fn latest_version(&self) -> Result<Option<String>> {\n        self.get(KEY_LATEST_VERSION).await\n    }\n\n    pub async fn save_latest_version(&self, version: &str) -> Result<()> {\n        self.set(KEY_LATEST_VERSION, version).await\n    }\n\n    pub async fn session_token(&self) -> Result<Option<String>> {\n        self.get(KEY_SESSION).await\n    }\n\n    pub async fn save_session(&self, token: &str) -> Result<()> {\n        self.set(KEY_SESSION, token).await\n    }\n\n    pub async fn delete_session(&self) -> Result<()> {\n        self.delete(KEY_SESSION).await\n    }\n\n    pub async fn logged_in(&self) -> Result<bool> {\n        Ok(self.session_token().await?.is_some() || self.hub_session_token().await?.is_some())\n    }\n\n    // Hub session methods (separate from sync session, used for Hub-specific features like AI)\n\n    pub async fn hub_session_token(&self) -> Result<Option<String>> {\n        self.get(KEY_HUB_SESSION).await\n    }\n\n    pub async fn save_hub_session(&self, token: &str) -> Result<()> {\n        self.set(KEY_HUB_SESSION, token).await\n    }\n\n    pub async fn delete_hub_session(&self) -> Result<()> {\n        self.delete(KEY_HUB_SESSION).await\n    }\n\n    pub async fn hub_logged_in(&self) -> Result<bool> {\n        Ok(self.hub_session_token().await?.is_some())\n    }\n\n    // File migration: on first open, migrate old plain-text files into the database.\n    // Old files are left in place for safe downgrades.\n\n    async fn migrate_files(&self) -> Result<()> {\n        if self.get(KEY_FILES_MIGRATED).await?.is_some() {\n            return Ok(());\n        }\n\n        let data_dir = crate::settings::Settings::effective_data_dir();\n\n        // host_id — validate as UUID\n        let host_id_path = data_dir.join(LEGACY_HOST_ID_FILENAME);\n        if host_id_path.exists()\n            && let Ok(value) = fs_err::read_to_string(&host_id_path)\n        {\n            let value = value.trim();\n            if !value.is_empty() {\n                if Uuid::from_str(value).is_ok() {\n                    self.set(KEY_HOST_ID, value).await?;\n                } else {\n                    warn!(\"skipping migration of host_id: invalid UUID {value:?}\");\n                }\n            }\n        }\n\n        // last_sync_time — validate as RFC3339\n        let sync_path = data_dir.join(LEGACY_LAST_SYNC_FILENAME);\n        if sync_path.exists()\n            && let Ok(value) = fs_err::read_to_string(&sync_path)\n        {\n            let value = value.trim();\n            if !value.is_empty() {\n                if OffsetDateTime::parse(value, &Rfc3339).is_ok() {\n                    self.set(KEY_LAST_SYNC, value).await?;\n                } else {\n                    warn!(\"skipping migration of last_sync_time: invalid RFC3339 {value:?}\");\n                }\n            }\n        }\n\n        // last_version_check_time — validate as RFC3339\n        let version_check_path = data_dir.join(LEGACY_LAST_VERSION_CHECK_FILENAME);\n        if version_check_path.exists()\n            && let Ok(value) = fs_err::read_to_string(&version_check_path)\n        {\n            let value = value.trim();\n            if !value.is_empty() {\n                if OffsetDateTime::parse(value, &Rfc3339).is_ok() {\n                    self.set(KEY_LAST_VERSION_CHECK, value).await?;\n                } else {\n                    warn!(\n                        \"skipping migration of last_version_check_time: invalid RFC3339 {value:?}\"\n                    );\n                }\n            }\n        }\n\n        // latest_version — no strict validation, just non-empty\n        let latest_version_path = data_dir.join(LEGACY_LATEST_VERSION_FILENAME);\n        if latest_version_path.exists()\n            && let Ok(value) = fs_err::read_to_string(&latest_version_path)\n        {\n            let value = value.trim();\n            if !value.is_empty() {\n                self.set(KEY_LATEST_VERSION, value).await?;\n            }\n        }\n\n        // session token — no strict validation, just non-empty\n        let session_path = data_dir.join(LEGACY_SESSION_FILENAME);\n        if session_path.exists()\n            && let Ok(value) = fs_err::read_to_string(&session_path)\n        {\n            let value = value.trim();\n            if !value.is_empty() {\n                self.set(KEY_SESSION, value).await?;\n            }\n        }\n\n        self.set(KEY_FILES_MIGRATED, \"true\").await?;\n\n        Ok(())\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    async fn new_test_store() -> MetaStore {\n        MetaStore::new(\"sqlite::memory:\", 2.0).await.unwrap()\n    }\n\n    #[tokio::test]\n    async fn test_get_set_delete() {\n        let store = new_test_store().await;\n\n        assert_eq!(store.get(\"foo\").await.unwrap(), None);\n\n        store.set(\"foo\", \"bar\").await.unwrap();\n        assert_eq!(store.get(\"foo\").await.unwrap(), Some(\"bar\".to_string()));\n\n        store.set(\"foo\", \"baz\").await.unwrap();\n        assert_eq!(store.get(\"foo\").await.unwrap(), Some(\"baz\".to_string()));\n\n        store.delete(\"foo\").await.unwrap();\n        assert_eq!(store.get(\"foo\").await.unwrap(), None);\n    }\n\n    #[tokio::test]\n    async fn test_host_id_generation_and_stability() {\n        let store = new_test_store().await;\n\n        let id1 = store.host_id().await.unwrap();\n        let id2 = store.host_id().await.unwrap();\n\n        assert_eq!(id1, id2, \"host_id should be stable across calls\");\n    }\n\n    #[tokio::test]\n    async fn test_sync_time() {\n        let store = new_test_store().await;\n\n        let t = store.last_sync().await.unwrap();\n        assert_eq!(t, OffsetDateTime::UNIX_EPOCH);\n\n        store.save_sync_time().await.unwrap();\n        let t = store.last_sync().await.unwrap();\n        assert!(t > OffsetDateTime::UNIX_EPOCH);\n    }\n\n    #[tokio::test]\n    async fn test_version_check_time() {\n        let store = new_test_store().await;\n\n        let t = store.last_version_check().await.unwrap();\n        assert_eq!(t, OffsetDateTime::UNIX_EPOCH);\n\n        store.save_version_check_time().await.unwrap();\n        let t = store.last_version_check().await.unwrap();\n        assert!(t > OffsetDateTime::UNIX_EPOCH);\n    }\n\n    #[tokio::test]\n    async fn test_session_crud() {\n        let store = new_test_store().await;\n\n        assert!(!store.logged_in().await.unwrap());\n        assert_eq!(store.session_token().await.unwrap(), None);\n\n        store.save_session(\"tok123\").await.unwrap();\n        assert!(store.logged_in().await.unwrap());\n        assert_eq!(\n            store.session_token().await.unwrap(),\n            Some(\"tok123\".to_string())\n        );\n\n        store.delete_session().await.unwrap();\n        assert!(!store.logged_in().await.unwrap());\n    }\n\n    #[tokio::test]\n    async fn test_latest_version() {\n        let store = new_test_store().await;\n\n        assert_eq!(store.latest_version().await.unwrap(), None);\n\n        store.save_latest_version(\"1.2.3\").await.unwrap();\n        assert_eq!(\n            store.latest_version().await.unwrap(),\n            Some(\"1.2.3\".to_string())\n        );\n    }\n}\n"
  },
  {
    "path": "crates/atuin-client/src/ordering.rs",
    "content": "use minspan::minspan;\n\nuse super::{history::History, settings::SearchMode};\n\npub fn reorder_fuzzy(mode: SearchMode, query: &str, res: Vec<History>) -> Vec<History> {\n    match mode {\n        SearchMode::Fuzzy => reorder(query, |x| &x.command, res),\n        _ => res,\n    }\n}\n\nfn reorder<F, A>(query: &str, f: F, res: Vec<A>) -> Vec<A>\nwhere\n    F: Fn(&A) -> &String,\n    A: Clone,\n{\n    let mut r = res.clone();\n    let qvec = &query.chars().collect();\n    r.sort_by_cached_key(|h| {\n        // TODO for fzf search we should sum up scores for each matched term\n        let (from, to) = match minspan::span(qvec, &(f(h).chars().collect())) {\n            Some(x) => x,\n            // this is a little unfortunate: when we are asked to match a query that is found nowhere,\n            // we don't want to return a None, as the comparison behaviour would put the worst matches\n            // at the front. therefore, we'll return a set of indices that are one larger than the longest\n            // possible legitimate match. This is meaningless except as a comparison.\n            None => (0, res.len()),\n        };\n        1 + to - from\n    });\n    r\n}\n"
  },
  {
    "path": "crates/atuin-client/src/plugin.rs",
    "content": "use std::collections::HashMap;\n\n#[derive(Debug, Clone)]\npub struct OfficialPlugin {\n    pub name: String,\n    pub description: String,\n    pub install_message: String,\n}\n\nimpl OfficialPlugin {\n    pub fn new(name: &str, description: &str, install_message: &str) -> Self {\n        Self {\n            name: name.to_string(),\n            description: description.to_string(),\n            install_message: install_message.to_string(),\n        }\n    }\n}\n\npub struct OfficialPluginRegistry {\n    plugins: HashMap<String, OfficialPlugin>,\n}\n\nimpl OfficialPluginRegistry {\n    pub fn new() -> Self {\n        let mut registry = Self {\n            plugins: HashMap::new(),\n        };\n\n        // Register official plugins\n        registry.register_official_plugins();\n\n        registry\n    }\n\n    fn register_official_plugins(&mut self) {\n        // atuin-update plugin\n        self.plugins.insert(\n            \"update\".to_string(),\n            OfficialPlugin::new(\n                \"update\",\n                \"Update atuin to the latest version\",\n                \"The 'atuin update' command is provided by the atuin-update plugin.\\n\\\n                 It is only installed if you used the install script\\n  \\\n                 If you used a package manager (brew, apt, etc), please continue to use it for updates\"\n            ),\n        );\n    }\n\n    pub fn get_plugin(&self, name: &str) -> Option<&OfficialPlugin> {\n        self.plugins.get(name)\n    }\n\n    pub fn is_official_plugin(&self, name: &str) -> bool {\n        self.plugins.contains_key(name)\n    }\n\n    pub fn get_install_message(&self, name: &str) -> Option<&str> {\n        self.plugins\n            .get(name)\n            .map(|plugin| plugin.install_message.as_str())\n    }\n}\n\nimpl Default for OfficialPluginRegistry {\n    fn default() -> Self {\n        Self::new()\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn test_registry_creation() {\n        let registry = OfficialPluginRegistry::new();\n        assert!(registry.is_official_plugin(\"update\"));\n        assert!(!registry.is_official_plugin(\"nonexistent\"));\n    }\n\n    #[test]\n    fn test_get_plugin() {\n        let registry = OfficialPluginRegistry::new();\n        let plugin = registry.get_plugin(\"update\");\n        assert!(plugin.is_some());\n        assert_eq!(plugin.unwrap().name, \"update\");\n    }\n\n    #[test]\n    fn test_get_install_message() {\n        let registry = OfficialPluginRegistry::new();\n        let message = registry.get_install_message(\"update\");\n        assert!(message.is_some());\n        assert!(message.unwrap().contains(\"atuin-update\"));\n    }\n}\n"
  },
  {
    "path": "crates/atuin-client/src/record/encryption.rs",
    "content": "use atuin_common::record::{\n    AdditionalData, DecryptedData, EncryptedData, Encryption, HostId, RecordId, RecordIdx,\n};\nuse base64::{Engine, engine::general_purpose};\nuse eyre::{Context, Result, ensure};\nuse rusty_paserk::{Key, KeyId, Local, PieWrappedKey};\nuse rusty_paseto::core::{\n    ImplicitAssertion, Key as DataKey, Local as LocalPurpose, Paseto, PasetoNonce, Payload, V4,\n};\nuse serde::{Deserialize, Serialize};\n\n/// Use PASETO V4 Local encryption using the additional data as an implicit assertion.\n#[allow(non_camel_case_types)]\npub struct PASETO_V4;\n\n/*\nWhy do we use a random content-encryption key?\nOriginally I was planning on using a derived key for encryption based on additional data.\nThis would be a lot more secure than using the master key directly.\n\nHowever, there's an established norm of using a random key. This scheme might be otherwise known as\n- client-side encryption\n- envelope encryption\n- key wrapping\n\nA HSM (Hardware Security Module) provider, eg: AWS, Azure, GCP, or even a physical device like a YubiKey\nwill have some keys that they keep to themselves. These keys never leave their physical hardware.\nIf they never leave the hardware, then encrypting large amounts of data means giving them the data and waiting.\nThis is not a practical solution. Instead, generate a unique key for your data, encrypt that using your HSM\nand then store that with your data.\n\nSee\n - <https://docs.aws.amazon.com/wellarchitected/latest/financial-services-industry-lens/use-envelope-encryption-with-customer-master-keys.html>\n - <https://cloud.google.com/kms/docs/envelope-encryption>\n - <https://learn.microsoft.com/en-us/azure/storage/blobs/client-side-encryption?tabs=dotnet#encryption-and-decryption-via-the-envelope-technique>\n - <https://www.yubico.com/gb/product/yubihsm-2-fips/>\n - <https://cheatsheetseries.owasp.org/cheatsheets/Cryptographic_Storage_Cheat_Sheet.html#encrypting-stored-keys>\n\nWhy would we care? In the past we have received some requests for company solutions. If in future we can configure a\nKMS service with little effort, then that would solve a lot of issues for their security team.\n\nEven for personal use, if a user is not comfortable with sharing keys between hosts,\nGCP HSM costs $1/month and $0.03 per 10,000 key operations. Assuming an active user runs\n1000 atuin records a day, that would only cost them $1 and 10 cent a month.\n\nAdditionally, key rotations are much simpler using this scheme. Rotating a key is as simple as re-encrypting the CEK, and not the message contents.\nThis makes it very fast to rotate a key in bulk.\n\nFor future reference, with asymmetric encryption, you can encrypt the CEK without the HSM's involvement, but decrypting\nwill need the HSM. This allows the encryption path to still be extremely fast (no network calls) but downloads/decryption\nthat happens in the background can make the network calls to the HSM\n*/\n\nimpl Encryption for PASETO_V4 {\n    fn re_encrypt(\n        mut data: EncryptedData,\n        _ad: AdditionalData,\n        old_key: &[u8; 32],\n        new_key: &[u8; 32],\n    ) -> Result<EncryptedData> {\n        let cek = Self::decrypt_cek(data.content_encryption_key, old_key)?;\n        data.content_encryption_key = Self::encrypt_cek(cek, new_key);\n        Ok(data)\n    }\n\n    fn encrypt(data: DecryptedData, ad: AdditionalData, key: &[u8; 32]) -> EncryptedData {\n        // generate a random key for this entry\n        // aka content-encryption-key (CEK)\n        let random_key = Key::<V4, Local>::new_os_random();\n\n        // encode the implicit assertions\n        let assertions = Assertions::from(ad).encode();\n\n        // build the payload and encrypt the token\n        let payload = serde_json::to_string(&AtuinPayload {\n            data: general_purpose::URL_SAFE_NO_PAD.encode(data.0),\n        })\n        .expect(\"json encoding can't fail\");\n        let nonce = DataKey::<32>::try_new_random().expect(\"could not source from random\");\n        let nonce = PasetoNonce::<V4, LocalPurpose>::from(&nonce);\n\n        let token = Paseto::<V4, LocalPurpose>::builder()\n            .set_payload(Payload::from(payload.as_str()))\n            .set_implicit_assertion(ImplicitAssertion::from(assertions.as_str()))\n            .try_encrypt(&random_key.into(), &nonce)\n            .expect(\"error encrypting atuin data\");\n\n        EncryptedData {\n            data: token,\n            content_encryption_key: Self::encrypt_cek(random_key, key),\n        }\n    }\n\n    fn decrypt(data: EncryptedData, ad: AdditionalData, key: &[u8; 32]) -> Result<DecryptedData> {\n        let token = data.data;\n        let cek = Self::decrypt_cek(data.content_encryption_key, key)?;\n\n        // encode the implicit assertions\n        let assertions = Assertions::from(ad).encode();\n\n        // decrypt the payload with the footer and implicit assertions\n        let payload = Paseto::<V4, LocalPurpose>::try_decrypt(\n            &token,\n            &cek.into(),\n            None,\n            ImplicitAssertion::from(&*assertions),\n        )\n        .context(\"could not decrypt entry\")?;\n\n        let payload: AtuinPayload = serde_json::from_str(&payload)?;\n        let data = general_purpose::URL_SAFE_NO_PAD.decode(payload.data)?;\n        Ok(DecryptedData(data))\n    }\n}\n\nimpl PASETO_V4 {\n    fn decrypt_cek(wrapped_cek: String, key: &[u8; 32]) -> Result<Key<V4, Local>> {\n        let wrapping_key = Key::<V4, Local>::from_bytes(*key);\n\n        // let wrapping_key = PasetoSymmetricKey::from(Key::from(key));\n\n        let AtuinFooter { kid, wpk } = serde_json::from_str(&wrapped_cek)\n            .context(\"wrapped cek did not contain the correct contents\")?;\n\n        // check that the wrapping key matches the required key to decrypt.\n        // In future, we could support multiple keys and use this key to\n        // look up the key rather than only allow one key.\n        // For now though we will only support the one key and key rotation will\n        // have to be a hard reset\n        let current_kid = wrapping_key.to_id();\n\n        ensure!(\n            current_kid == kid,\n            \"attempting to decrypt with incorrect key. currently using {current_kid}, expecting {kid}\"\n        );\n\n        // decrypt the random key\n        Ok(wpk.unwrap_key(&wrapping_key)?)\n    }\n\n    fn encrypt_cek(cek: Key<V4, Local>, key: &[u8; 32]) -> String {\n        // aka key-encryption-key (KEK)\n        let wrapping_key = Key::<V4, Local>::from_bytes(*key);\n\n        // wrap the random key so we can decrypt it later\n        let wrapped_cek = AtuinFooter {\n            wpk: cek.wrap_pie(&wrapping_key),\n            kid: wrapping_key.to_id(),\n        };\n        serde_json::to_string(&wrapped_cek).expect(\"could not serialize wrapped cek\")\n    }\n}\n\n#[derive(Serialize, Deserialize)]\nstruct AtuinPayload {\n    data: String,\n}\n\n#[derive(Serialize, Deserialize)]\n/// Well-known footer claims for decrypting. This is not encrypted but is stored in the record.\n/// <https://github.com/paseto-standard/paseto-spec/blob/master/docs/02-Implementation-Guide/04-Claims.md#optional-footer-claims>\nstruct AtuinFooter {\n    /// Wrapped key\n    wpk: PieWrappedKey<V4, Local>,\n    /// ID of the key which was used to wrap\n    kid: KeyId<V4, Local>,\n}\n\n/// Used in the implicit assertions. This is not encrypted and not stored in the data blob.\n// This cannot be changed, otherwise it breaks the authenticated encryption.\n#[derive(Debug, Copy, Clone, Serialize)]\nstruct Assertions<'a> {\n    id: &'a RecordId,\n    idx: &'a RecordIdx,\n    version: &'a str,\n    tag: &'a str,\n    host: &'a HostId,\n}\n\nimpl<'a> From<AdditionalData<'a>> for Assertions<'a> {\n    fn from(ad: AdditionalData<'a>) -> Self {\n        Self {\n            id: ad.id,\n            version: ad.version,\n            tag: ad.tag,\n            host: ad.host,\n            idx: ad.idx,\n        }\n    }\n}\n\nimpl Assertions<'_> {\n    fn encode(&self) -> String {\n        serde_json::to_string(self).expect(\"could not serialize implicit assertions\")\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use atuin_common::{\n        record::{Host, Record},\n        utils::uuid_v7,\n    };\n\n    use super::*;\n\n    #[test]\n    fn round_trip() {\n        let key = Key::<V4, Local>::new_os_random();\n\n        let ad = AdditionalData {\n            id: &RecordId(uuid_v7()),\n            version: \"v0\",\n            tag: \"kv\",\n            host: &HostId(uuid_v7()),\n            idx: &0,\n        };\n\n        let data = DecryptedData(vec![1, 2, 3, 4]);\n\n        let encrypted = PASETO_V4::encrypt(data.clone(), ad, &key.to_bytes());\n        let decrypted = PASETO_V4::decrypt(encrypted, ad, &key.to_bytes()).unwrap();\n        assert_eq!(decrypted, data);\n    }\n\n    #[test]\n    fn same_entry_different_output() {\n        let key = Key::<V4, Local>::new_os_random();\n\n        let ad = AdditionalData {\n            id: &RecordId(uuid_v7()),\n            version: \"v0\",\n            tag: \"kv\",\n            host: &HostId(uuid_v7()),\n            idx: &0,\n        };\n\n        let data = DecryptedData(vec![1, 2, 3, 4]);\n\n        let encrypted = PASETO_V4::encrypt(data.clone(), ad, &key.to_bytes());\n        let encrypted2 = PASETO_V4::encrypt(data, ad, &key.to_bytes());\n\n        assert_ne!(\n            encrypted.data, encrypted2.data,\n            \"re-encrypting the same contents should have different output due to key randomization\"\n        );\n    }\n\n    #[test]\n    fn cannot_decrypt_different_key() {\n        let key = Key::<V4, Local>::new_os_random();\n        let fake_key = Key::<V4, Local>::new_os_random();\n\n        let ad = AdditionalData {\n            id: &RecordId(uuid_v7()),\n            version: \"v0\",\n            tag: \"kv\",\n            host: &HostId(uuid_v7()),\n            idx: &0,\n        };\n\n        let data = DecryptedData(vec![1, 2, 3, 4]);\n\n        let encrypted = PASETO_V4::encrypt(data, ad, &key.to_bytes());\n        let _ = PASETO_V4::decrypt(encrypted, ad, &fake_key.to_bytes()).unwrap_err();\n    }\n\n    #[test]\n    fn cannot_decrypt_different_id() {\n        let key = Key::<V4, Local>::new_os_random();\n\n        let ad = AdditionalData {\n            id: &RecordId(uuid_v7()),\n            version: \"v0\",\n            tag: \"kv\",\n            host: &HostId(uuid_v7()),\n            idx: &0,\n        };\n\n        let data = DecryptedData(vec![1, 2, 3, 4]);\n\n        let encrypted = PASETO_V4::encrypt(data, ad, &key.to_bytes());\n\n        let ad = AdditionalData {\n            id: &RecordId(uuid_v7()),\n            ..ad\n        };\n        let _ = PASETO_V4::decrypt(encrypted, ad, &key.to_bytes()).unwrap_err();\n    }\n\n    #[test]\n    fn re_encrypt_round_trip() {\n        let key1 = Key::<V4, Local>::new_os_random();\n        let key2 = Key::<V4, Local>::new_os_random();\n\n        let ad = AdditionalData {\n            id: &RecordId(uuid_v7()),\n            version: \"v0\",\n            tag: \"kv\",\n            host: &HostId(uuid_v7()),\n            idx: &0,\n        };\n\n        let data = DecryptedData(vec![1, 2, 3, 4]);\n\n        let encrypted1 = PASETO_V4::encrypt(data.clone(), ad, &key1.to_bytes());\n        let encrypted2 =\n            PASETO_V4::re_encrypt(encrypted1.clone(), ad, &key1.to_bytes(), &key2.to_bytes())\n                .unwrap();\n\n        // we only re-encrypt the content keys\n        assert_eq!(encrypted1.data, encrypted2.data);\n        assert_ne!(\n            encrypted1.content_encryption_key,\n            encrypted2.content_encryption_key\n        );\n\n        let decrypted = PASETO_V4::decrypt(encrypted2, ad, &key2.to_bytes()).unwrap();\n\n        assert_eq!(decrypted, data);\n    }\n\n    #[test]\n    fn full_record_round_trip() {\n        let key = [0x55; 32];\n        let record = Record::builder()\n            .id(RecordId(uuid_v7()))\n            .version(\"v0\".to_owned())\n            .tag(\"kv\".to_owned())\n            .host(Host::new(HostId(uuid_v7())))\n            .timestamp(1687244806000000)\n            .data(DecryptedData(vec![1, 2, 3, 4]))\n            .idx(0)\n            .build();\n\n        let encrypted = record.encrypt::<PASETO_V4>(&key);\n\n        assert!(!encrypted.data.data.is_empty());\n        assert!(!encrypted.data.content_encryption_key.is_empty());\n\n        let decrypted = encrypted.decrypt::<PASETO_V4>(&key).unwrap();\n\n        assert_eq!(decrypted.data.0, [1, 2, 3, 4]);\n    }\n\n    #[test]\n    fn full_record_round_trip_fail() {\n        let key = [0x55; 32];\n        let record = Record::builder()\n            .id(RecordId(uuid_v7()))\n            .version(\"v0\".to_owned())\n            .tag(\"kv\".to_owned())\n            .host(Host::new(HostId(uuid_v7())))\n            .timestamp(1687244806000000)\n            .data(DecryptedData(vec![1, 2, 3, 4]))\n            .idx(0)\n            .build();\n\n        let encrypted = record.encrypt::<PASETO_V4>(&key);\n\n        let mut enc1 = encrypted.clone();\n        enc1.host = Host::new(HostId(uuid_v7()));\n        let _ = enc1\n            .decrypt::<PASETO_V4>(&key)\n            .expect_err(\"tampering with the host should result in auth failure\");\n\n        let mut enc2 = encrypted;\n        enc2.id = RecordId(uuid_v7());\n        let _ = enc2\n            .decrypt::<PASETO_V4>(&key)\n            .expect_err(\"tampering with the id should result in auth failure\");\n    }\n}\n"
  },
  {
    "path": "crates/atuin-client/src/record/mod.rs",
    "content": "pub mod encryption;\npub mod sqlite_store;\npub mod store;\n\n#[cfg(feature = \"sync\")]\npub mod sync;\n"
  },
  {
    "path": "crates/atuin-client/src/record/sqlite_store.rs",
    "content": "// Here we are using sqlite as a pretty dumb store, and will not be running any complex queries.\n// Multiple stores of multiple types are all stored in one chonky table (for now), and we just index\n// by tag/host\n\nuse std::str::FromStr;\nuse std::{path::Path, time::Duration};\n\nuse async_trait::async_trait;\nuse eyre::{Result, eyre};\nuse fs_err as fs;\n\nuse sqlx::{\n    Row,\n    sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePool, SqlitePoolOptions, SqliteRow},\n};\n\nuse atuin_common::record::{\n    EncryptedData, Host, HostId, Record, RecordId, RecordIdx, RecordStatus,\n};\nuse atuin_common::utils;\nuse uuid::Uuid;\n\nuse super::encryption::PASETO_V4;\nuse super::store::Store;\n\n#[derive(Debug, Clone)]\npub struct SqliteStore {\n    pool: SqlitePool,\n}\n\nimpl SqliteStore {\n    pub async fn new(path: impl AsRef<Path>, timeout: f64) -> Result<Self> {\n        let path = path.as_ref();\n\n        debug!(\"opening sqlite database at {path:?}\");\n\n        if utils::broken_symlink(path) {\n            eprintln!(\n                \"Atuin: Sqlite db path ({path:?}) is a broken symlink. Unable to read or create replacement.\"\n            );\n            std::process::exit(1);\n        }\n\n        if !path.exists()\n            && let Some(dir) = path.parent()\n        {\n            fs::create_dir_all(dir)?;\n        }\n\n        let opts = SqliteConnectOptions::from_str(path.as_os_str().to_str().unwrap())?\n            .journal_mode(SqliteJournalMode::Wal)\n            .foreign_keys(true)\n            .create_if_missing(true);\n\n        let pool = SqlitePoolOptions::new()\n            .acquire_timeout(Duration::from_secs_f64(timeout))\n            .connect_with(opts)\n            .await?;\n\n        Self::setup_db(&pool).await?;\n\n        Ok(Self { pool })\n    }\n\n    async fn setup_db(pool: &SqlitePool) -> Result<()> {\n        debug!(\"running sqlite database setup\");\n\n        sqlx::migrate!(\"./record-migrations\").run(pool).await?;\n\n        Ok(())\n    }\n\n    async fn save_raw(\n        tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,\n        r: &Record<EncryptedData>,\n    ) -> Result<()> {\n        // In sqlite, we are \"limited\" to i64. But that is still fine, until 2262.\n        sqlx::query(\n            \"insert or ignore into store(id, idx, host, tag, timestamp, version, data, cek)\n                values(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)\",\n        )\n        .bind(r.id.0.as_hyphenated().to_string())\n        .bind(r.idx as i64)\n        .bind(r.host.id.0.as_hyphenated().to_string())\n        .bind(r.tag.as_str())\n        .bind(r.timestamp as i64)\n        .bind(r.version.as_str())\n        .bind(r.data.data.as_str())\n        .bind(r.data.content_encryption_key.as_str())\n        .execute(&mut **tx)\n        .await?;\n\n        Ok(())\n    }\n\n    fn query_row(row: SqliteRow) -> Record<EncryptedData> {\n        let idx: i64 = row.get(\"idx\");\n        let timestamp: i64 = row.get(\"timestamp\");\n\n        // tbh at this point things are pretty fucked so just panic\n        let id = Uuid::from_str(row.get(\"id\")).expect(\"invalid id UUID format in sqlite DB\");\n        let host = Uuid::from_str(row.get(\"host\")).expect(\"invalid host UUID format in sqlite DB\");\n\n        Record {\n            id: RecordId(id),\n            idx: idx as u64,\n            host: Host::new(HostId(host)),\n            timestamp: timestamp as u64,\n            tag: row.get(\"tag\"),\n            version: row.get(\"version\"),\n            data: EncryptedData {\n                data: row.get(\"data\"),\n                content_encryption_key: row.get(\"cek\"),\n            },\n        }\n    }\n\n    async fn load_all(&self) -> Result<Vec<Record<EncryptedData>>> {\n        let res = sqlx::query(\"select * from store \")\n            .map(Self::query_row)\n            .fetch_all(&self.pool)\n            .await?;\n\n        Ok(res)\n    }\n}\n\n#[async_trait]\nimpl Store for SqliteStore {\n    async fn push_batch(\n        &self,\n        records: impl Iterator<Item = &Record<EncryptedData>> + Send + Sync,\n    ) -> Result<()> {\n        let mut tx = self.pool.begin().await?;\n\n        for record in records {\n            Self::save_raw(&mut tx, record).await?;\n        }\n\n        tx.commit().await?;\n\n        Ok(())\n    }\n\n    async fn get(&self, id: RecordId) -> Result<Record<EncryptedData>> {\n        let res = sqlx::query(\"select * from store where store.id = ?1\")\n            .bind(id.0.as_hyphenated().to_string())\n            .map(Self::query_row)\n            .fetch_one(&self.pool)\n            .await?;\n\n        Ok(res)\n    }\n\n    async fn delete(&self, id: RecordId) -> Result<()> {\n        sqlx::query(\"delete from store where id = ?1\")\n            .bind(id.0.as_hyphenated().to_string())\n            .execute(&self.pool)\n            .await?;\n\n        Ok(())\n    }\n\n    async fn delete_all(&self) -> Result<()> {\n        sqlx::query(\"delete from store\").execute(&self.pool).await?;\n\n        Ok(())\n    }\n\n    async fn last(&self, host: HostId, tag: &str) -> Result<Option<Record<EncryptedData>>> {\n        let res =\n            sqlx::query(\"select * from store where host=?1 and tag=?2 order by idx desc limit 1\")\n                .bind(host.0.as_hyphenated().to_string())\n                .bind(tag)\n                .map(Self::query_row)\n                .fetch_one(&self.pool)\n                .await;\n\n        match res {\n            Err(sqlx::Error::RowNotFound) => Ok(None),\n            Err(e) => Err(eyre!(\"an error occurred: {}\", e)),\n            Ok(record) => Ok(Some(record)),\n        }\n    }\n\n    async fn first(&self, host: HostId, tag: &str) -> Result<Option<Record<EncryptedData>>> {\n        self.idx(host, tag, 0).await\n    }\n\n    async fn len_all(&self) -> Result<u64> {\n        let res: Result<(i64,), sqlx::Error> = sqlx::query_as(\"select count(*) from store\")\n            .fetch_one(&self.pool)\n            .await;\n        match res {\n            Err(e) => Err(eyre!(\"failed to fetch local store len: {}\", e)),\n            Ok(v) => Ok(v.0 as u64),\n        }\n    }\n\n    async fn len_tag(&self, tag: &str) -> Result<u64> {\n        let res: Result<(i64,), sqlx::Error> =\n            sqlx::query_as(\"select count(*) from store where tag=?1\")\n                .bind(tag)\n                .fetch_one(&self.pool)\n                .await;\n        match res {\n            Err(e) => Err(eyre!(\"failed to fetch local store len: {}\", e)),\n            Ok(v) => Ok(v.0 as u64),\n        }\n    }\n\n    async fn len(&self, host: HostId, tag: &str) -> Result<u64> {\n        let last = self.last(host, tag).await?;\n\n        if let Some(last) = last {\n            return Ok(last.idx + 1);\n        }\n\n        return Ok(0);\n    }\n\n    async fn next(\n        &self,\n        host: HostId,\n        tag: &str,\n        idx: RecordIdx,\n        limit: u64,\n    ) -> Result<Vec<Record<EncryptedData>>> {\n        let res = sqlx::query(\n            \"select * from store where idx >= ?1 and host = ?2 and tag = ?3 order by idx asc limit ?4\",\n        )\n        .bind(idx as i64)\n        .bind(host.0.as_hyphenated().to_string())\n        .bind(tag)\n        .bind(limit as i64)\n        .map(Self::query_row)\n        .fetch_all(&self.pool)\n        .await?;\n\n        Ok(res)\n    }\n\n    async fn idx(\n        &self,\n        host: HostId,\n        tag: &str,\n        idx: RecordIdx,\n    ) -> Result<Option<Record<EncryptedData>>> {\n        let res = sqlx::query(\"select * from store where idx = ?1 and host = ?2 and tag = ?3\")\n            .bind(idx as i64)\n            .bind(host.0.as_hyphenated().to_string())\n            .bind(tag)\n            .map(Self::query_row)\n            .fetch_one(&self.pool)\n            .await;\n\n        match res {\n            Err(sqlx::Error::RowNotFound) => Ok(None),\n            Err(e) => Err(eyre!(\"an error occurred: {}\", e)),\n            Ok(v) => Ok(Some(v)),\n        }\n    }\n\n    async fn status(&self) -> Result<RecordStatus> {\n        let mut status = RecordStatus::new();\n\n        let res: Result<Vec<(String, String, i64)>, sqlx::Error> =\n            sqlx::query_as(\"select host, tag, max(idx) from store group by host, tag\")\n                .fetch_all(&self.pool)\n                .await;\n\n        let res = match res {\n            Err(e) => return Err(eyre!(\"failed to fetch local store status: {}\", e)),\n            Ok(v) => v,\n        };\n\n        for i in res {\n            let host = HostId(\n                Uuid::from_str(i.0.as_str()).expect(\"failed to parse uuid for local store status\"),\n            );\n\n            status.set_raw(host, i.1, i.2 as u64);\n        }\n\n        Ok(status)\n    }\n\n    async fn all_tagged(&self, tag: &str) -> Result<Vec<Record<EncryptedData>>> {\n        let res = sqlx::query(\"select * from store where tag = ?1 order by timestamp asc\")\n            .bind(tag)\n            .map(Self::query_row)\n            .fetch_all(&self.pool)\n            .await?;\n\n        Ok(res)\n    }\n\n    /// Reencrypt every single item in this store with a new key\n    /// Be careful - this may mess with sync.\n    async fn re_encrypt(&self, old_key: &[u8; 32], new_key: &[u8; 32]) -> Result<()> {\n        // Load all the records\n        // In memory like some of the other code here\n        // This will never be called in a hot loop, and only under the following circumstances\n        // 1. The user has logged into a new account, with a new key. They are unlikely to have a\n        //    lot of data\n        // 2. The user has encountered some sort of issue, and runs a maintenance command that\n        //    invokes this\n        let all = self.load_all().await?;\n\n        let re_encrypted = all\n            .into_iter()\n            .map(|record| record.re_encrypt::<PASETO_V4>(old_key, new_key))\n            .collect::<Result<Vec<_>>>()?;\n\n        // next up, we delete all the old data and reinsert the new stuff\n        // do it in one transaction, so if anything fails we rollback OK\n\n        let mut tx = self.pool.begin().await?;\n\n        let res = sqlx::query(\"delete from store\").execute(&mut *tx).await?;\n\n        let rows = res.rows_affected();\n        debug!(\"deleted {rows} rows\");\n\n        // don't call push_batch, as it will start its own transaction\n        // call the underlying save_raw\n\n        for record in re_encrypted {\n            Self::save_raw(&mut tx, &record).await?;\n        }\n\n        tx.commit().await?;\n\n        Ok(())\n    }\n\n    /// Verify that every record in this store can be decrypted with the current key\n    /// Someday maybe also check each tag/record can be deserialized, but not for now.\n    async fn verify(&self, key: &[u8; 32]) -> Result<()> {\n        let all = self.load_all().await?;\n\n        all.into_iter()\n            .map(|record| record.decrypt::<PASETO_V4>(key))\n            .collect::<Result<Vec<_>>>()?;\n\n        Ok(())\n    }\n\n    /// Verify that every record in this store can be decrypted with the current key\n    /// Someday maybe also check each tag/record can be deserialized, but not for now.\n    async fn purge(&self, key: &[u8; 32]) -> Result<()> {\n        let all = self.load_all().await?;\n\n        for record in all.iter() {\n            match record.clone().decrypt::<PASETO_V4>(key) {\n                Ok(_) => continue,\n                Err(_) => {\n                    println!(\n                        \"Failed to decrypt {}, deleting\",\n                        record.id.0.as_hyphenated()\n                    );\n\n                    self.delete(record.id).await?;\n                }\n            }\n        }\n\n        Ok(())\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use atuin_common::{\n        record::{DecryptedData, EncryptedData, Host, HostId, Record},\n        utils::uuid_v7,\n    };\n\n    use crate::{\n        encryption::generate_encoded_key,\n        record::{encryption::PASETO_V4, store::Store},\n        settings::test_local_timeout,\n    };\n\n    use super::SqliteStore;\n\n    fn test_record() -> Record<EncryptedData> {\n        Record::builder()\n            .host(Host::new(HostId(atuin_common::utils::uuid_v7())))\n            .version(\"v1\".into())\n            .tag(atuin_common::utils::uuid_v7().simple().to_string())\n            .data(EncryptedData {\n                data: \"1234\".into(),\n                content_encryption_key: \"1234\".into(),\n            })\n            .idx(0)\n            .build()\n    }\n\n    #[tokio::test]\n    async fn create_db() {\n        let db = SqliteStore::new(\":memory:\", test_local_timeout()).await;\n\n        assert!(\n            db.is_ok(),\n            \"db could not be created, {:?}\",\n            db.err().unwrap()\n        );\n    }\n\n    #[tokio::test]\n    async fn push_record() {\n        let db = SqliteStore::new(\":memory:\", test_local_timeout())\n            .await\n            .unwrap();\n        let record = test_record();\n\n        db.push(&record).await.expect(\"failed to insert record\");\n    }\n\n    #[tokio::test]\n    async fn get_record() {\n        let db = SqliteStore::new(\":memory:\", test_local_timeout())\n            .await\n            .unwrap();\n        let record = test_record();\n        db.push(&record).await.unwrap();\n\n        let new_record = db.get(record.id).await.expect(\"failed to fetch record\");\n\n        assert_eq!(record, new_record, \"records are not equal\");\n    }\n\n    #[tokio::test]\n    async fn last() {\n        let db = SqliteStore::new(\":memory:\", test_local_timeout())\n            .await\n            .unwrap();\n        let record = test_record();\n        db.push(&record).await.unwrap();\n\n        let last = db\n            .last(record.host.id, record.tag.as_str())\n            .await\n            .expect(\"failed to get store len\");\n\n        assert_eq!(\n            last.unwrap().id,\n            record.id,\n            \"expected to get back the same record that was inserted\"\n        );\n    }\n\n    #[tokio::test]\n    async fn first() {\n        let db = SqliteStore::new(\":memory:\", test_local_timeout())\n            .await\n            .unwrap();\n        let record = test_record();\n        db.push(&record).await.unwrap();\n\n        let first = db\n            .first(record.host.id, record.tag.as_str())\n            .await\n            .expect(\"failed to get store len\");\n\n        assert_eq!(\n            first.unwrap().id,\n            record.id,\n            \"expected to get back the same record that was inserted\"\n        );\n    }\n\n    #[tokio::test]\n    async fn len() {\n        let db = SqliteStore::new(\":memory:\", test_local_timeout())\n            .await\n            .unwrap();\n        let record = test_record();\n        db.push(&record).await.unwrap();\n\n        let len = db\n            .len(record.host.id, record.tag.as_str())\n            .await\n            .expect(\"failed to get store len\");\n\n        assert_eq!(len, 1, \"expected length of 1 after insert\");\n    }\n\n    #[tokio::test]\n    async fn len_tag() {\n        let db = SqliteStore::new(\":memory:\", test_local_timeout())\n            .await\n            .unwrap();\n        let record = test_record();\n        db.push(&record).await.unwrap();\n\n        let len = db\n            .len_tag(record.tag.as_str())\n            .await\n            .expect(\"failed to get store len\");\n\n        assert_eq!(len, 1, \"expected length of 1 after insert\");\n    }\n\n    #[tokio::test]\n    async fn len_different_tags() {\n        let db = SqliteStore::new(\":memory:\", test_local_timeout())\n            .await\n            .unwrap();\n\n        // these have different tags, so the len should be the same\n        // we model multiple stores within one database\n        // new store = new tag = independent length\n        let first = test_record();\n        let second = test_record();\n\n        db.push(&first).await.unwrap();\n        db.push(&second).await.unwrap();\n\n        let first_len = db.len(first.host.id, first.tag.as_str()).await.unwrap();\n        let second_len = db.len(second.host.id, second.tag.as_str()).await.unwrap();\n\n        assert_eq!(first_len, 1, \"expected length of 1 after insert\");\n        assert_eq!(second_len, 1, \"expected length of 1 after insert\");\n    }\n\n    #[tokio::test]\n    async fn append_a_bunch() {\n        let db = SqliteStore::new(\":memory:\", test_local_timeout())\n            .await\n            .unwrap();\n\n        let mut tail = test_record();\n        db.push(&tail).await.expect(\"failed to push record\");\n\n        for _ in 1..100 {\n            tail = tail.append(vec![1, 2, 3, 4]).encrypt::<PASETO_V4>(&[0; 32]);\n            db.push(&tail).await.unwrap();\n        }\n\n        assert_eq!(\n            db.len(tail.host.id, tail.tag.as_str()).await.unwrap(),\n            100,\n            \"failed to insert 100 records\"\n        );\n\n        assert_eq!(\n            db.len_tag(tail.tag.as_str()).await.unwrap(),\n            100,\n            \"failed to insert 100 records\"\n        );\n    }\n\n    #[tokio::test]\n    async fn append_a_big_bunch() {\n        let db = SqliteStore::new(\":memory:\", test_local_timeout())\n            .await\n            .unwrap();\n\n        let mut records: Vec<Record<EncryptedData>> = Vec::with_capacity(10000);\n\n        let mut tail = test_record();\n        records.push(tail.clone());\n\n        for _ in 1..10000 {\n            tail = tail.append(vec![1, 2, 3]).encrypt::<PASETO_V4>(&[0; 32]);\n            records.push(tail.clone());\n        }\n\n        db.push_batch(records.iter()).await.unwrap();\n\n        assert_eq!(\n            db.len(tail.host.id, tail.tag.as_str()).await.unwrap(),\n            10000,\n            \"failed to insert 10k records\"\n        );\n    }\n\n    #[tokio::test]\n    async fn re_encrypt() {\n        let store = SqliteStore::new(\":memory:\", test_local_timeout())\n            .await\n            .unwrap();\n        let (key, _) = generate_encoded_key().unwrap();\n        let data = vec![0u8, 1u8, 2u8, 3u8];\n        let host_id = HostId(uuid_v7());\n\n        for i in 0..10 {\n            let record = Record::builder()\n                .host(Host::new(host_id))\n                .version(String::from(\"test\"))\n                .tag(String::from(\"test\"))\n                .idx(i)\n                .data(DecryptedData(data.clone()))\n                .build();\n\n            let record = record.encrypt::<PASETO_V4>(&key.into());\n            store\n                .push(&record)\n                .await\n                .expect(\"failed to push encrypted record\");\n        }\n\n        // first, check that we can decrypt the data with the current key\n        let all = store.all_tagged(\"test\").await.unwrap();\n\n        assert_eq!(all.len(), 10, \"failed to fetch all records\");\n\n        for record in all {\n            let decrypted = record.decrypt::<PASETO_V4>(&key.into()).unwrap();\n            assert_eq!(decrypted.data.0, data);\n        }\n\n        // reencrypt the store, then check if\n        // 1) it cannot be decrypted with the old key\n        // 2) it can be decrypted with the new key\n\n        let (new_key, _) = generate_encoded_key().unwrap();\n        store\n            .re_encrypt(&key.into(), &new_key.into())\n            .await\n            .expect(\"failed to re-encrypt store\");\n\n        let all = store.all_tagged(\"test\").await.unwrap();\n\n        for record in all.iter() {\n            let decrypted = record.clone().decrypt::<PASETO_V4>(&key.into());\n            assert!(\n                decrypted.is_err(),\n                \"did not get error decrypting with old key after re-encrypt\"\n            )\n        }\n\n        for record in all {\n            let decrypted = record.decrypt::<PASETO_V4>(&new_key.into()).unwrap();\n            assert_eq!(decrypted.data.0, data);\n        }\n\n        assert_eq!(store.len(host_id, \"test\").await.unwrap(), 10);\n    }\n}\n"
  },
  {
    "path": "crates/atuin-client/src/record/store.rs",
    "content": "use async_trait::async_trait;\nuse eyre::Result;\n\nuse atuin_common::record::{EncryptedData, HostId, Record, RecordId, RecordIdx, RecordStatus};\n\n/// A record store stores records\n/// In more detail - we tend to need to process this into _another_ format to actually query it.\n/// As is, the record store is intended as the source of truth for arbitrary data, which could\n/// be shell history, kvs, etc.\n#[async_trait]\npub trait Store {\n    // Push a record\n    async fn push(&self, record: &Record<EncryptedData>) -> Result<()> {\n        self.push_batch(std::iter::once(record)).await\n    }\n\n    // Push a batch of records, all in one transaction\n    async fn push_batch(\n        &self,\n        records: impl Iterator<Item = &Record<EncryptedData>> + Send + Sync,\n    ) -> Result<()>;\n\n    async fn get(&self, id: RecordId) -> Result<Record<EncryptedData>>;\n\n    async fn delete(&self, id: RecordId) -> Result<()>;\n    async fn delete_all(&self) -> Result<()>;\n\n    async fn len_all(&self) -> Result<u64>;\n    async fn len(&self, host: HostId, tag: &str) -> Result<u64>;\n    async fn len_tag(&self, tag: &str) -> Result<u64>;\n\n    async fn last(&self, host: HostId, tag: &str) -> Result<Option<Record<EncryptedData>>>;\n    async fn first(&self, host: HostId, tag: &str) -> Result<Option<Record<EncryptedData>>>;\n\n    async fn re_encrypt(&self, old_key: &[u8; 32], new_key: &[u8; 32]) -> Result<()>;\n    async fn verify(&self, key: &[u8; 32]) -> Result<()>;\n    async fn purge(&self, key: &[u8; 32]) -> Result<()>;\n\n    /// Get the next `limit` records, after and including the given index\n    async fn next(\n        &self,\n        host: HostId,\n        tag: &str,\n        idx: RecordIdx,\n        limit: u64,\n    ) -> Result<Vec<Record<EncryptedData>>>;\n\n    /// Get the first record for a given host and tag\n    async fn idx(\n        &self,\n        host: HostId,\n        tag: &str,\n        idx: RecordIdx,\n    ) -> Result<Option<Record<EncryptedData>>>;\n\n    async fn status(&self) -> Result<RecordStatus>;\n\n    /// Get all records for a given tag\n    async fn all_tagged(&self, tag: &str) -> Result<Vec<Record<EncryptedData>>>;\n}\n"
  },
  {
    "path": "crates/atuin-client/src/record/sync.rs",
    "content": "// do a sync :O\nuse std::{cmp::Ordering, fmt::Write};\n\nuse eyre::Result;\nuse thiserror::Error;\n\nuse super::store::Store;\nuse crate::{api_client::Client, settings::Settings};\n\nuse atuin_common::record::{Diff, HostId, RecordId, RecordIdx, RecordStatus};\nuse indicatif::{ProgressBar, ProgressState, ProgressStyle};\n\n#[derive(Error, Debug)]\npub enum SyncError {\n    #[error(\"the local store is ahead of the remote, but for another host. has remote lost data?\")]\n    LocalAheadOtherHost,\n\n    #[error(\"an issue with the local database occurred: {msg:?}\")]\n    LocalStoreError { msg: String },\n\n    #[error(\"something has gone wrong with the sync logic: {msg:?}\")]\n    SyncLogicError { msg: String },\n\n    #[error(\"operational error: {msg:?}\")]\n    OperationalError { msg: String },\n\n    #[error(\"a request to the sync server failed: {msg:?}\")]\n    RemoteRequestError { msg: String },\n}\n\n#[derive(Debug, Eq, PartialEq)]\npub enum Operation {\n    // Either upload or download until the states matches the below\n    Upload {\n        local: RecordIdx,\n        remote: Option<RecordIdx>,\n        host: HostId,\n        tag: String,\n    },\n    Download {\n        local: Option<RecordIdx>,\n        remote: RecordIdx,\n        host: HostId,\n        tag: String,\n    },\n    Noop {\n        host: HostId,\n        tag: String,\n    },\n}\n\npub async fn diff(\n    settings: &Settings,\n    store: &impl Store,\n) -> Result<(Vec<Diff>, RecordStatus), SyncError> {\n    let client = Client::new(\n        &settings.sync_address,\n        settings\n            .sync_auth_token()\n            .await\n            .map_err(|e| SyncError::RemoteRequestError { msg: e.to_string() })?,\n        settings.network_connect_timeout,\n        settings.network_timeout,\n    )\n    .map_err(|e| SyncError::OperationalError { msg: e.to_string() })?;\n\n    let local_index = store\n        .status()\n        .await\n        .map_err(|e| SyncError::LocalStoreError { msg: e.to_string() })?;\n\n    let remote_index = client\n        .record_status()\n        .await\n        .map_err(|e| SyncError::RemoteRequestError { msg: e.to_string() })?;\n\n    let diff = local_index.diff(&remote_index);\n\n    Ok((diff, remote_index))\n}\n\n// Take a diff, along with a local store, and resolve it into a set of operations.\n// With the store as context, we can determine if a tail exists locally or not and therefore if it needs uploading or download.\n// In theory this could be done as a part of the diffing stage, but it's easier to reason\n// about and test this way\npub async fn operations(\n    diffs: Vec<Diff>,\n    _store: &impl Store,\n) -> Result<Vec<Operation>, SyncError> {\n    let mut operations = Vec::with_capacity(diffs.len());\n\n    for diff in diffs {\n        let op = match (diff.local, diff.remote) {\n            // We both have it! Could be either. Compare.\n            (Some(local), Some(remote)) => match local.cmp(&remote) {\n                Ordering::Equal => Operation::Noop {\n                    host: diff.host,\n                    tag: diff.tag,\n                },\n                Ordering::Greater => Operation::Upload {\n                    local,\n                    remote: Some(remote),\n                    host: diff.host,\n                    tag: diff.tag,\n                },\n                Ordering::Less => Operation::Download {\n                    local: Some(local),\n                    remote,\n                    host: diff.host,\n                    tag: diff.tag,\n                },\n            },\n\n            // Remote has it, we don't. Gotta be download\n            (None, Some(remote)) => Operation::Download {\n                local: None,\n                remote,\n                host: diff.host,\n                tag: diff.tag,\n            },\n\n            // We have it, remote doesn't. Gotta be upload.\n            (Some(local), None) => Operation::Upload {\n                local,\n                remote: None,\n                host: diff.host,\n                tag: diff.tag,\n            },\n\n            // something is pretty fucked.\n            (None, None) => {\n                return Err(SyncError::SyncLogicError {\n                    msg: String::from(\n                        \"diff has nothing for local or remote - (host, tag) does not exist\",\n                    ),\n                });\n            }\n        };\n\n        operations.push(op);\n    }\n\n    // sort them - purely so we have a stable testing order, and can rely on\n    // same input = same output\n    // We can sort by ID so long as we continue to use UUIDv7 or something\n    // with the same properties\n\n    operations.sort_by_key(|op| match op {\n        Operation::Noop { host, tag } => (0, *host, tag.clone()),\n\n        Operation::Upload { host, tag, .. } => (1, *host, tag.clone()),\n\n        Operation::Download { host, tag, .. } => (2, *host, tag.clone()),\n    });\n\n    Ok(operations)\n}\n\nasync fn sync_upload(\n    store: &impl Store,\n    client: &Client<'_>,\n    host: HostId,\n    tag: String,\n    local: RecordIdx,\n    remote: Option<RecordIdx>,\n    page_size: u64,\n) -> Result<i64, SyncError> {\n    let remote = remote.unwrap_or(0);\n    let expected = local - remote;\n    let mut progress = 0;\n\n    let pb = ProgressBar::new(expected);\n    pb.set_style(ProgressStyle::with_template(\"{spinner:.green} [{elapsed_precise}] [{wide_bar:.cyan/blue}] {human_pos}/{human_len} ({eta})\")\n        .unwrap()\n        .with_key(\"eta\", |state: &ProgressState, w: &mut dyn Write| write!(w, \"{:.1}s\", state.eta().as_secs_f64()).unwrap())\n        .progress_chars(\"#>-\"));\n\n    println!(\n        \"Uploading {} records to {}/{}\",\n        expected,\n        host.0.as_simple(),\n        tag\n    );\n\n    loop {\n        let page = store\n            .next(host, tag.as_str(), remote + progress, page_size)\n            .await\n            .map_err(|e| {\n                error!(\"failed to read upload page: {e:?}\");\n\n                SyncError::LocalStoreError { msg: e.to_string() }\n            })?;\n\n        if page.is_empty() {\n            break;\n        }\n\n        client.post_records(&page).await.map_err(|e| {\n            error!(\"failed to post records: {e:?}\");\n\n            SyncError::RemoteRequestError { msg: e.to_string() }\n        })?;\n\n        progress += page.len() as u64;\n        pb.set_position(progress);\n\n        if progress >= expected {\n            break;\n        }\n    }\n\n    pb.finish_with_message(\"Uploaded records\");\n\n    Ok(progress as i64)\n}\n\nasync fn sync_download(\n    store: &impl Store,\n    client: &Client<'_>,\n    host: HostId,\n    tag: String,\n    local: Option<RecordIdx>,\n    remote: RecordIdx,\n    page_size: u64,\n) -> Result<Vec<RecordId>, SyncError> {\n    let local = local.unwrap_or(0);\n    let expected = remote - local;\n    let mut progress = 0;\n    let mut ret = Vec::new();\n\n    println!(\n        \"Downloading {} records from {}/{}\",\n        expected,\n        host.0.as_simple(),\n        tag\n    );\n\n    let pb = ProgressBar::new(expected);\n    pb.set_style(ProgressStyle::with_template(\"{spinner:.green} [{elapsed_precise}] [{wide_bar:.cyan/blue}] {human_pos}/{human_len} ({eta})\")\n        .unwrap()\n        .with_key(\"eta\", |state: &ProgressState, w: &mut dyn Write| write!(w, \"{:.1}s\", state.eta().as_secs_f64()).unwrap())\n        .progress_chars(\"#>-\"));\n\n    loop {\n        let page = client\n            .next_records(host, tag.clone(), local + progress, page_size)\n            .await\n            .map_err(|e| SyncError::RemoteRequestError { msg: e.to_string() })?;\n\n        if page.is_empty() {\n            break;\n        }\n\n        store\n            .push_batch(page.iter())\n            .await\n            .map_err(|e| SyncError::LocalStoreError { msg: e.to_string() })?;\n\n        ret.extend(page.iter().map(|f| f.id));\n\n        progress += page.len() as u64;\n        pb.set_position(progress);\n\n        if progress >= expected {\n            break;\n        }\n    }\n\n    pb.finish_with_message(\"Downloaded records\");\n\n    Ok(ret)\n}\n\npub async fn sync_remote(\n    operations: Vec<Operation>,\n    local_store: &impl Store,\n    settings: &Settings,\n    page_size: u64,\n) -> Result<(i64, Vec<RecordId>), SyncError> {\n    let client = Client::new(\n        &settings.sync_address,\n        settings\n            .sync_auth_token()\n            .await\n            .map_err(|e| SyncError::RemoteRequestError { msg: e.to_string() })?,\n        settings.network_connect_timeout,\n        settings.network_timeout,\n    )\n    .expect(\"failed to create client\");\n\n    let mut uploaded = 0;\n    let mut downloaded = Vec::new();\n\n    // this can totally run in parallel, but lets get it working first\n    for i in operations {\n        match i {\n            Operation::Upload {\n                host,\n                tag,\n                local,\n                remote,\n            } => {\n                uploaded +=\n                    sync_upload(local_store, &client, host, tag, local, remote, page_size).await?\n            }\n\n            Operation::Download {\n                host,\n                tag,\n                local,\n                remote,\n            } => {\n                let mut d =\n                    sync_download(local_store, &client, host, tag, local, remote, page_size)\n                        .await?;\n                downloaded.append(&mut d)\n            }\n\n            Operation::Noop { .. } => continue,\n        }\n    }\n\n    Ok((uploaded, downloaded))\n}\n\npub async fn sync(\n    settings: &Settings,\n    store: &impl Store,\n) -> Result<(i64, Vec<RecordId>), SyncError> {\n    let (diff, _) = diff(settings, store).await?;\n    let operations = operations(diff, store).await?;\n    let (uploaded, downloaded) = sync_remote(operations, store, settings, 100).await?;\n\n    Ok((uploaded, downloaded))\n}\n\n#[cfg(test)]\nmod tests {\n    use atuin_common::record::{Diff, EncryptedData, HostId, Record};\n    use pretty_assertions::assert_eq;\n\n    use crate::{\n        record::{\n            encryption::PASETO_V4,\n            sqlite_store::SqliteStore,\n            store::Store,\n            sync::{self, Operation},\n        },\n        settings::test_local_timeout,\n    };\n\n    fn test_record() -> Record<EncryptedData> {\n        Record::builder()\n            .host(atuin_common::record::Host::new(HostId(\n                atuin_common::utils::uuid_v7(),\n            )))\n            .version(\"v1\".into())\n            .tag(atuin_common::utils::uuid_v7().simple().to_string())\n            .data(EncryptedData {\n                data: String::new(),\n                content_encryption_key: String::new(),\n            })\n            .idx(0)\n            .build()\n    }\n\n    // Take a list of local records, and a list of remote records.\n    // Return the local database, and a diff of local/remote, ready to build\n    // ops\n    async fn build_test_diff(\n        local_records: Vec<Record<EncryptedData>>,\n        remote_records: Vec<Record<EncryptedData>>,\n    ) -> (SqliteStore, Vec<Diff>) {\n        let local_store = SqliteStore::new(\":memory:\", test_local_timeout())\n            .await\n            .expect(\"failed to open in memory sqlite\");\n        let remote_store = SqliteStore::new(\":memory:\", test_local_timeout())\n            .await\n            .expect(\"failed to open in memory sqlite\"); // \"remote\"\n\n        for i in local_records {\n            local_store.push(&i).await.unwrap();\n        }\n\n        for i in remote_records {\n            remote_store.push(&i).await.unwrap();\n        }\n\n        let local_index = local_store.status().await.unwrap();\n        let remote_index = remote_store.status().await.unwrap();\n\n        let diff = local_index.diff(&remote_index);\n\n        (local_store, diff)\n    }\n\n    #[tokio::test]\n    async fn test_basic_diff() {\n        // a diff where local is ahead of remote. nothing else.\n\n        let record = test_record();\n        let (store, diff) = build_test_diff(vec![record.clone()], vec![]).await;\n\n        assert_eq!(diff.len(), 1);\n\n        let operations = sync::operations(diff, &store).await.unwrap();\n\n        assert_eq!(operations.len(), 1);\n\n        assert_eq!(\n            operations[0],\n            Operation::Upload {\n                host: record.host.id,\n                tag: record.tag,\n                local: record.idx,\n                remote: None,\n            }\n        );\n    }\n\n    #[tokio::test]\n    async fn build_two_way_diff() {\n        // a diff where local is ahead of remote for one, and remote for\n        // another. One upload, one download\n\n        let shared_record = test_record();\n        let remote_ahead = test_record();\n\n        let local_ahead = shared_record\n            .append(vec![1, 2, 3])\n            .encrypt::<PASETO_V4>(&[0; 32]);\n\n        assert_eq!(local_ahead.idx, 1);\n\n        let local = vec![shared_record.clone(), local_ahead.clone()]; // local knows about the already synced, and something newer in the same store\n        let remote = vec![shared_record.clone(), remote_ahead.clone()]; // remote knows about the already-synced, and one new record in a new store\n\n        let (store, diff) = build_test_diff(local, remote).await;\n        let operations = sync::operations(diff, &store).await.unwrap();\n\n        assert_eq!(operations.len(), 2);\n\n        assert_eq!(\n            operations,\n            vec![\n                // Or in otherwords, local is ahead by one\n                Operation::Upload {\n                    host: local_ahead.host.id,\n                    tag: local_ahead.tag,\n                    local: 1,\n                    remote: Some(0),\n                },\n                // Or in other words, remote knows of a record in an entirely new store (tag)\n                Operation::Download {\n                    host: remote_ahead.host.id,\n                    tag: remote_ahead.tag,\n                    local: None,\n                    remote: 0,\n                },\n            ]\n        );\n    }\n\n    #[tokio::test]\n    async fn build_complex_diff() {\n        // One shared, ahead but known only by remote\n        // One known only by local\n        // One known only by remote\n\n        let shared_record = test_record();\n        let local_only = test_record();\n\n        let local_only_20 = test_record();\n        let local_only_21 = local_only_20\n            .append(vec![1, 2, 3])\n            .encrypt::<PASETO_V4>(&[0; 32]);\n        let local_only_22 = local_only_21\n            .append(vec![1, 2, 3])\n            .encrypt::<PASETO_V4>(&[0; 32]);\n        let local_only_23 = local_only_22\n            .append(vec![1, 2, 3])\n            .encrypt::<PASETO_V4>(&[0; 32]);\n\n        let remote_only = test_record();\n\n        let remote_only_20 = test_record();\n        let remote_only_21 = remote_only_20\n            .append(vec![2, 3, 2])\n            .encrypt::<PASETO_V4>(&[0; 32]);\n        let remote_only_22 = remote_only_21\n            .append(vec![2, 3, 2])\n            .encrypt::<PASETO_V4>(&[0; 32]);\n        let remote_only_23 = remote_only_22\n            .append(vec![2, 3, 2])\n            .encrypt::<PASETO_V4>(&[0; 32]);\n        let remote_only_24 = remote_only_23\n            .append(vec![2, 3, 2])\n            .encrypt::<PASETO_V4>(&[0; 32]);\n\n        let second_shared = test_record();\n        let second_shared_remote_ahead = second_shared\n            .append(vec![1, 2, 3])\n            .encrypt::<PASETO_V4>(&[0; 32]);\n        let second_shared_remote_ahead2 = second_shared_remote_ahead\n            .append(vec![1, 2, 3])\n            .encrypt::<PASETO_V4>(&[0; 32]);\n\n        let third_shared = test_record();\n        let third_shared_local_ahead = third_shared\n            .append(vec![1, 2, 3])\n            .encrypt::<PASETO_V4>(&[0; 32]);\n        let third_shared_local_ahead2 = third_shared_local_ahead\n            .append(vec![1, 2, 3])\n            .encrypt::<PASETO_V4>(&[0; 32]);\n\n        let fourth_shared = test_record();\n        let fourth_shared_remote_ahead = fourth_shared\n            .append(vec![1, 2, 3])\n            .encrypt::<PASETO_V4>(&[0; 32]);\n        let fourth_shared_remote_ahead2 = fourth_shared_remote_ahead\n            .append(vec![1, 2, 3])\n            .encrypt::<PASETO_V4>(&[0; 32]);\n\n        let local = vec![\n            shared_record.clone(),\n            second_shared.clone(),\n            third_shared.clone(),\n            fourth_shared.clone(),\n            fourth_shared_remote_ahead.clone(),\n            // single store, only local has it\n            local_only.clone(),\n            // bigger store, also only known by local\n            local_only_20.clone(),\n            local_only_21.clone(),\n            local_only_22.clone(),\n            local_only_23.clone(),\n            // another shared store, but local is ahead on this one\n            third_shared_local_ahead.clone(),\n            third_shared_local_ahead2.clone(),\n        ];\n\n        let remote = vec![\n            remote_only.clone(),\n            remote_only_20.clone(),\n            remote_only_21.clone(),\n            remote_only_22.clone(),\n            remote_only_23.clone(),\n            remote_only_24.clone(),\n            shared_record.clone(),\n            second_shared.clone(),\n            third_shared.clone(),\n            second_shared_remote_ahead.clone(),\n            second_shared_remote_ahead2.clone(),\n            fourth_shared.clone(),\n            fourth_shared_remote_ahead.clone(),\n            fourth_shared_remote_ahead2.clone(),\n        ]; // remote knows about the already-synced, and one new record in a new store\n\n        let (store, diff) = build_test_diff(local, remote).await;\n        let operations = sync::operations(diff, &store).await.unwrap();\n\n        assert_eq!(operations.len(), 7);\n\n        let mut result_ops = vec![\n            // We started with a shared record, but the remote knows of two newer records in the\n            // same store\n            Operation::Download {\n                local: Some(0),\n                remote: 2,\n                host: second_shared_remote_ahead.host.id,\n                tag: second_shared_remote_ahead.tag,\n            },\n            // We have a shared record, local knows of the first two but not the last\n            Operation::Download {\n                local: Some(1),\n                remote: 2,\n                host: fourth_shared_remote_ahead2.host.id,\n                tag: fourth_shared_remote_ahead2.tag,\n            },\n            // Remote knows of a store with a single record that local does not have\n            Operation::Download {\n                local: None,\n                remote: 0,\n                host: remote_only.host.id,\n                tag: remote_only.tag,\n            },\n            // Remote knows of a store with a bunch of records that local does not have\n            Operation::Download {\n                local: None,\n                remote: 4,\n                host: remote_only_20.host.id,\n                tag: remote_only_20.tag,\n            },\n            // Local knows of a record in a store that remote does not have\n            Operation::Upload {\n                local: 0,\n                remote: None,\n                host: local_only.host.id,\n                tag: local_only.tag,\n            },\n            // Local knows of 4 records in a store that remote does not have\n            Operation::Upload {\n                local: 3,\n                remote: None,\n                host: local_only_20.host.id,\n                tag: local_only_20.tag,\n            },\n            // Local knows of 2 more records in a shared store that remote only has one of\n            Operation::Upload {\n                local: 2,\n                remote: Some(0),\n                host: third_shared.host.id,\n                tag: third_shared.tag,\n            },\n        ];\n\n        result_ops.sort_by_key(|op| match op {\n            Operation::Noop { host, tag } => (0, *host, tag.clone()),\n\n            Operation::Upload { host, tag, .. } => (1, *host, tag.clone()),\n\n            Operation::Download { host, tag, .. } => (2, *host, tag.clone()),\n        });\n\n        assert_eq!(result_ops, operations);\n    }\n}\n"
  },
  {
    "path": "crates/atuin-client/src/register.rs",
    "content": "use eyre::Result;\n\nuse crate::{api_client, settings::Settings};\n\npub async fn register_classic(\n    settings: &Settings,\n    username: String,\n    email: String,\n    password: String,\n) -> Result<String> {\n    let session =\n        api_client::register(settings.sync_address.as_str(), &username, &email, &password).await?;\n\n    let meta = Settings::meta_store().await?;\n    meta.save_session(&session.session).await?;\n\n    let _key = crate::encryption::load_key(settings)?;\n\n    Ok(session.session)\n}\n"
  },
  {
    "path": "crates/atuin-client/src/secrets.rs",
    "content": "// This file will probably trigger a lot of scanners. Sorry.\n\nuse regex::RegexSet;\nuse std::sync::LazyLock;\n\npub enum TestValue<'a> {\n    Single(&'a str),\n    Multiple(&'a [&'a str]),\n}\n\n/// A list of `(name, regex, test)`, where `test` should match against `regex`.\npub static SECRET_PATTERNS: &[(&str, &str, TestValue)] = &[\n    (\n        \"AWS Access Key ID\",\n        \"A[KS]IA[0-9A-Z]{16}\",\n        TestValue::Single(\"AKIAIOSFODNN7EXAMPLE\"),\n    ),\n    (\n        \"AWS Secret Access Key env var\",\n        \"AWS_SECRET_ACCESS_KEY\",\n        TestValue::Single(\"AWS_SECRET_ACCESS_KEY=KEYDATA\"),\n    ),\n    (\n        \"AWS Session Token env var\",\n        \"AWS_SESSION_TOKEN\",\n        TestValue::Single(\"AWS_SESSION_TOKEN=KEYDATA\"),\n    ),\n    (\n        \"Microsoft Azure secret access key env var\",\n        \"AZURE_.*_KEY\",\n        TestValue::Single(\"export AZURE_STORAGE_ACCOUNT_KEY=KEYDATA\"),\n    ),\n    (\n        \"Google cloud platform key env var\",\n        \"GOOGLE_SERVICE_ACCOUNT_KEY\",\n        TestValue::Single(\"export GOOGLE_SERVICE_ACCOUNT_KEY=KEYDATA\"),\n    ),\n    (\n        \"Atuin login\",\n        r\"atuin\\s+login\",\n        TestValue::Single(\n            \"atuin login -u mycoolusername -p mycoolpassword -k \\\"lots of random words\\\"\",\n        ),\n    ),\n    (\n        \"GitHub PAT (old)\",\n        \"ghp_[a-zA-Z0-9]{36}\",\n        TestValue::Single(\"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\"), // legit, I expired it\n    ),\n    (\n        \"GitHub PAT (new)\",\n        \"gh1_[A-Za-z0-9]{21}_[A-Za-z0-9]{59}|github_pat_[0-9][A-Za-z0-9]{21}_[A-Za-z0-9]{59}\",\n        TestValue::Multiple(&[\n            \"gh1_1234567890abcdefghijk_1234567890abcdefghijklmnopqrstuvwxyz1234567890abcdefghijklm\",\n            \"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\", // also legit, also expired\n        ]),\n    ),\n    (\n        \"GitHub OAuth Access Token\",\n        \"gho_[A-Za-z0-9]{36}\",\n        TestValue::Single(\"gho_1234567890abcdefghijklmnopqrstuvwx000\"), // not a real token\n    ),\n    (\n        \"GitHub OAuth Access Token (user)\",\n        \"ghu_[A-Za-z0-9]{36}\",\n        TestValue::Single(\"ghu_1234567890abcdefghijklmnopqrstuvwx000\"), // not a real token\n    ),\n    (\n        \"GitHub App Installation Access Token\",\n        \"ghs_[A-Za-z0-9]{36}\",\n        TestValue::Single(\"ghs_1234567890abcdefghijklmnopqrstuvwx000\"), // not a real token\n    ),\n    (\n        \"GitHub Refresh Token\",\n        \"ghr_[A-Za-z0-9]{76}\",\n        TestValue::Single(\n            \"ghr_1234567890abcdefghijklmnopqrstuvwx1234567890abcdefghijklmnopqrstuvwx1234567890abcdefghijklmnopqrstuvwx\",\n        ), // not a real token\n    ),\n    (\n        \"GitHub App Installation Access Token v1\",\n        \"v1\\\\.[0-9A-Fa-f]{40}\",\n        TestValue::Single(\"v1.1234567890abcdef1234567890abcdef12345678\"), // not a real token\n    ),\n    (\n        \"GitLab PAT\",\n        \"glpat-[a-zA-Z0-9_]{20}\",\n        TestValue::Single(\"glpat-RkE_BG5p_bbjML21WSfy\"),\n    ),\n    (\n        \"Slack OAuth v2 bot\",\n        \"xoxb-[0-9]{11}-[0-9]{11}-[0-9a-zA-Z]{24}\",\n        TestValue::Single(\"xoxb-17653672481-19874698323-pdFZKVeTuE8sk7oOcBrzbqgy\"),\n    ),\n    (\n        \"Slack OAuth v2 user token\",\n        \"xoxp-[0-9]{11}-[0-9]{11}-[0-9a-zA-Z]{24}\",\n        TestValue::Single(\"xoxp-17653672481-19874698323-pdFZKVeTuE8sk7oOcBrzbqgy\"),\n    ),\n    (\n        \"Slack webhook\",\n        \"T[a-zA-Z0-9_]{8}/B[a-zA-Z0-9_]{8}/[a-zA-Z0-9_]{24}\",\n        TestValue::Single(\n            \"https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX\",\n        ),\n    ),\n    (\n        \"Stripe test key\",\n        \"sk_test_[0-9a-zA-Z]{24}\",\n        TestValue::Single(\"sk_test_1234567890abcdefghijklmnop\"),\n    ),\n    (\n        \"Stripe live key\",\n        \"sk_live_[0-9a-zA-Z]{24}\",\n        TestValue::Single(\"sk_live_1234567890abcdefghijklmnop\"),\n    ),\n    (\n        \"Netlify authentication token\",\n        \"nf[pcoub]_[0-9a-zA-Z]{36}\",\n        TestValue::Single(\"nfp_nBh7BdJxUwyaBBwFzpyD29MMFT6pZ9wq5634\"),\n    ),\n    (\n        \"npm token\",\n        \"npm_[A-Za-z0-9]{36}\",\n        TestValue::Single(\"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\"),\n    ),\n    (\n        \"Pulumi personal access token\",\n        \"pul-[0-9a-f]{40}\",\n        TestValue::Single(\"pul-683c2770662c51d960d72ec27613be7653c5cb26\"),\n    ),\n];\n\n/// The `regex` expressions from [`SECRET_PATTERNS`] compiled into a `RegexSet`.\npub static SECRET_PATTERNS_RE: LazyLock<RegexSet> = LazyLock::new(|| {\n    let exprs = SECRET_PATTERNS.iter().map(|f| f.1);\n    RegexSet::new(exprs).expect(\"Failed to build secrets regex\")\n});\n\n#[cfg(test)]\nmod tests {\n    use regex::Regex;\n\n    use crate::secrets::{SECRET_PATTERNS, TestValue};\n\n    #[test]\n    fn test_secrets() {\n        for (name, regex, test) in SECRET_PATTERNS {\n            let re =\n                Regex::new(regex).unwrap_or_else(|_| panic!(\"Failed to compile regex for {name}\"));\n\n            match test {\n                TestValue::Single(test) => {\n                    assert!(re.is_match(test), \"{name} test failed!\");\n                }\n                TestValue::Multiple(tests) => {\n                    for test_str in tests.iter() {\n                        assert!(\n                            re.is_match(test_str),\n                            \"{name} test with value \\\"{test_str}\\\" failed!\"\n                        );\n                    }\n                }\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "crates/atuin-client/src/settings/dotfiles.rs",
    "content": "use serde::{Deserialize, Serialize};\n\n#[derive(Debug, Serialize, Deserialize, Clone, Default)]\npub struct Settings {\n    #[serde(alias = \"enable\")]\n    pub enabled: bool,\n}\n"
  },
  {
    "path": "crates/atuin-client/src/settings/kv.rs",
    "content": "use serde::{Deserialize, Serialize};\n\n#[derive(Debug, Serialize, Deserialize, Clone)]\npub struct Settings {\n    pub db_path: String,\n}\n\nimpl Default for Settings {\n    fn default() -> Self {\n        let dir = atuin_common::utils::data_dir();\n        let path = dir.join(\"kv.db\");\n\n        Self {\n            db_path: path.to_string_lossy().to_string(),\n        }\n    }\n}\n"
  },
  {
    "path": "crates/atuin-client/src/settings/meta.rs",
    "content": "use serde::{Deserialize, Serialize};\n\n#[derive(Debug, Serialize, Deserialize, Clone)]\npub struct Settings {\n    pub db_path: String,\n}\n\nimpl Default for Settings {\n    fn default() -> Self {\n        let dir = atuin_common::utils::data_dir();\n        let path = dir.join(\"meta.db\");\n\n        Self {\n            db_path: path.to_string_lossy().to_string(),\n        }\n    }\n}\n"
  },
  {
    "path": "crates/atuin-client/src/settings/scripts.rs",
    "content": "use serde::{Deserialize, Serialize};\n\n#[derive(Debug, Serialize, Deserialize, Clone)]\npub struct Settings {\n    pub db_path: String,\n}\n\nimpl Default for Settings {\n    fn default() -> Self {\n        let dir = atuin_common::utils::data_dir();\n        let path = dir.join(\"scripts.db\");\n\n        Self {\n            db_path: path.to_string_lossy().to_string(),\n        }\n    }\n}\n"
  },
  {
    "path": "crates/atuin-client/src/settings/watcher.rs",
    "content": "//! Config file watching for automatic settings reload.\n//!\n//! This module provides a `SettingsWatcher` that monitors the config file\n//! for changes and broadcasts updated settings via a `tokio::sync::watch` channel.\n//!\n//! # Example\n//!\n//! ```no_run\n//! use atuin_client::settings::watcher::global_settings_watcher;\n//!\n//! async fn example() -> eyre::Result<()> {\n//!     let watcher = global_settings_watcher()?;\n//!     let mut rx = watcher.subscribe();\n//!\n//!     // React to settings changes\n//!     while rx.changed().await.is_ok() {\n//!         let settings = rx.borrow();\n//!         println!(\"Settings updated!\");\n//!     }\n//!     Ok(())\n//! }\n//! ```\n\nuse std::{\n    path::PathBuf,\n    sync::{Arc, OnceLock},\n    time::Duration,\n};\n\nuse eyre::{Result, WrapErr};\nuse log::{debug, error, info, warn};\nuse notify::{\n    Config as NotifyConfig, RecommendedWatcher, RecursiveMode, Watcher,\n    event::{EventKind, ModifyKind},\n};\nuse tokio::sync::watch;\n\nuse super::Settings;\n\n/// Global singleton for the settings watcher.\nstatic SETTINGS_WATCHER: OnceLock<Result<SettingsWatcher, String>> = OnceLock::new();\n\n/// Get the global settings watcher singleton.\n///\n/// Initializes the watcher on first call. Subsequent calls return the same instance.\n/// The watcher monitors the config file for changes and broadcasts updates.\npub fn global_settings_watcher() -> Result<&'static SettingsWatcher> {\n    let result = SETTINGS_WATCHER.get_or_init(|| SettingsWatcher::new().map_err(|e| e.to_string()));\n\n    match result {\n        Ok(watcher) => Ok(watcher),\n        Err(e) => Err(eyre::eyre!(\"{}\", e)),\n    }\n}\n\n/// Watches the config file for changes and broadcasts updated settings.\n///\n/// Uses `notify` for cross-platform file watching and `tokio::sync::watch`\n/// for efficient broadcast to multiple subscribers.\npub struct SettingsWatcher {\n    /// Receiver for settings updates. Clone this to subscribe.\n    rx: watch::Receiver<Arc<Settings>>,\n    /// Keeps the file watcher alive for the lifetime of this struct.\n    _watcher: RecommendedWatcher,\n}\n\nimpl SettingsWatcher {\n    /// Create a new settings watcher.\n    ///\n    /// Loads initial settings and starts watching the config file for changes.\n    /// Changes are debounced (500ms) to avoid multiple reloads during saves.\n    pub fn new() -> Result<Self> {\n        let initial_settings = Arc::new(Settings::new()?);\n        let (tx, rx) = watch::channel(initial_settings);\n\n        let config_path = Self::config_path();\n        info!(\"starting config file watcher: {:?}\", config_path);\n\n        let watcher = Self::create_watcher(tx, config_path)?;\n\n        Ok(Self {\n            rx,\n            _watcher: watcher,\n        })\n    }\n\n    /// Subscribe to settings updates.\n    ///\n    /// Returns a receiver that will be notified when settings change.\n    /// Use `changed().await` to wait for the next update, then `borrow()`\n    /// to access the current settings.\n    pub fn subscribe(&self) -> watch::Receiver<Arc<Settings>> {\n        self.rx.clone()\n    }\n\n    /// Get the current settings without subscribing to updates.\n    pub fn current(&self) -> Arc<Settings> {\n        self.rx.borrow().clone()\n    }\n\n    /// Get the config file path.\n    fn config_path() -> PathBuf {\n        let config_dir = if let Ok(p) = std::env::var(\"ATUIN_CONFIG_DIR\") {\n            PathBuf::from(p)\n        } else {\n            atuin_common::utils::config_dir()\n        };\n        config_dir.join(\"config.toml\")\n    }\n\n    /// Create the file watcher with debouncing.\n    fn create_watcher(\n        tx: watch::Sender<Arc<Settings>>,\n        config_path: PathBuf,\n    ) -> Result<RecommendedWatcher> {\n        // Channel for debouncing file events\n        let (debounce_tx, debounce_rx) = std::sync::mpsc::channel::<()>();\n\n        // Spawn debounce thread\n        let config_path_clone = config_path.clone();\n        std::thread::spawn(move || {\n            Self::debounce_loop(debounce_rx, tx, config_path_clone);\n        });\n\n        // Clone config_path for use in the watcher callback\n        let config_path_for_watcher = config_path.clone();\n\n        // Canonicalize config path for reliable comparison on macOS\n        // (handles symlinks like /var -> /private/var)\n        let canonical_config_path = config_path_for_watcher\n            .canonicalize()\n            .unwrap_or_else(|_| config_path_for_watcher.clone());\n\n        // Create file watcher\n        let mut watcher = RecommendedWatcher::new(\n            move |res: Result<notify::Event, notify::Error>| {\n                match res {\n                    Ok(event) => {\n                        // Defensive: if paths is empty, we can't filter, so assume\n                        // it might be our config file and trigger a reload to be safe\n                        if event.paths.is_empty() {\n                            warn!(\n                                \"config watcher: event has no paths, triggering reload to be safe\"\n                            );\n                            let _ = debounce_tx.send(());\n                            return;\n                        }\n\n                        // Only react to events for our specific config file\n                        // (filter out editor temp files, backups, etc.)\n                        let is_config_file = event.paths.iter().any(|path| {\n                            // Canonicalize for reliable comparison (handles macOS symlinks)\n                            let canonical_event_path =\n                                path.canonicalize().unwrap_or_else(|_| path.clone());\n\n                            // Check if this event is for our config file\n                            // (either exact match or the file was renamed to our config)\n                            canonical_event_path == canonical_config_path\n                                || path.file_name() == config_path_for_watcher.file_name()\n                        });\n\n                        if !is_config_file {\n                            return;\n                        }\n\n                        // Only react to modify events (content changes) or creates\n                        if matches!(\n                            event.kind,\n                            EventKind::Modify(ModifyKind::Data(_) | ModifyKind::Any)\n                                | EventKind::Create(_)\n                        ) {\n                            debug!(\"config file event detected: {:?}\", event);\n                            // Send to debounce channel (ignore send errors - receiver might be gone)\n                            let _ = debounce_tx.send(());\n                        }\n                    }\n                    Err(e) => {\n                        error!(\"file watcher error: {}\", e);\n                    }\n                }\n            },\n            NotifyConfig::default(),\n        )\n        .wrap_err(\"failed to create file watcher\")?;\n\n        // Watch the config file's parent directory (some editors create new files)\n        let watch_path = config_path.parent().unwrap_or(&config_path);\n\n        // Defensive: ensure watch path exists before trying to watch\n        if !watch_path.exists() {\n            warn!(\n                \"config directory does not exist, creating it: {:?}\",\n                watch_path\n            );\n            std::fs::create_dir_all(watch_path)\n                .wrap_err_with(|| format!(\"failed to create config directory: {:?}\", watch_path))?;\n        }\n\n        watcher\n            .watch(watch_path, RecursiveMode::NonRecursive)\n            .wrap_err_with(|| format!(\"failed to watch config directory: {:?}\", watch_path))?;\n\n        info!(\"config file watcher initialized for: {:?}\", watch_path);\n        Ok(watcher)\n    }\n\n    /// Debounce loop that batches file events and reloads settings.\n    fn debounce_loop(\n        rx: std::sync::mpsc::Receiver<()>,\n        tx: watch::Sender<Arc<Settings>>,\n        config_path: PathBuf,\n    ) {\n        const DEBOUNCE_DURATION: Duration = Duration::from_millis(500);\n\n        loop {\n            // Wait for first event\n            if rx.recv().is_err() {\n                // Channel closed, watcher was dropped\n                debug!(\"config watcher debounce loop exiting\");\n                return;\n            }\n\n            // Drain any additional events within debounce window\n            while rx.recv_timeout(DEBOUNCE_DURATION).is_ok() {\n                // Keep draining\n            }\n\n            // Defensive: check if config file exists before reloading\n            // (handles case where file was deleted - we'll get notified when it's recreated)\n            if !config_path.exists() {\n                debug!(\n                    \"config file does not exist, skipping reload: {:?}\",\n                    config_path\n                );\n                continue;\n            }\n\n            // Now reload settings\n            info!(\"config file changed, reloading settings: {:?}\", config_path);\n            match Settings::new() {\n                Ok(settings) => {\n                    if tx.send(Arc::new(settings)).is_err() {\n                        // All receivers dropped\n                        debug!(\"all settings subscribers dropped, exiting\");\n                        return;\n                    }\n                    info!(\"settings reloaded successfully\");\n                }\n                Err(e) => {\n                    warn!(\"failed to reload settings: {}\", e);\n                    // Keep the old settings, don't broadcast the error\n                }\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "crates/atuin-client/src/settings.rs",
    "content": "use std::{collections::HashMap, fmt, io::prelude::*, path::PathBuf, str::FromStr, sync::OnceLock};\nuse tokio::sync::OnceCell;\n\nuse atuin_common::record::HostId;\nuse atuin_common::utils;\nuse clap::ValueEnum;\nuse config::{\n    Config, ConfigBuilder, Environment, File as ConfigFile, FileFormat, builder::DefaultState,\n};\nuse eyre::{Context, Error, Result, bail, eyre};\nuse fs_err::{File, create_dir_all};\nuse humantime::parse_duration;\nuse regex::RegexSet;\nuse semver::Version;\nuse serde::{Deserialize, Serialize};\nuse serde_with::DeserializeFromStr;\nuse time::{OffsetDateTime, UtcOffset, format_description::FormatItem, macros::format_description};\n\npub const HISTORY_PAGE_SIZE: i64 = 100;\nstatic EXAMPLE_CONFIG: &str = include_str!(\"../config.toml\");\n\nstatic DATA_DIR: OnceLock<PathBuf> = OnceLock::new();\nstatic META_CONFIG: OnceLock<(String, f64)> = OnceLock::new();\nstatic META_STORE: OnceCell<crate::meta::MetaStore> = OnceCell::const_new();\n\nmod dotfiles;\nmod kv;\npub(crate) mod meta;\nmod scripts;\npub mod watcher;\n\npub struct HubEndpoint(String);\n\n/// Default sync address for Atuin's hosted service\npub const DEFAULT_SYNC_ADDRESS: &str = \"https://api.atuin.sh\";\n\n/// Default Hub web/API endpoint for Atuin's hosted service\npub const DEFAULT_HUB_ENDPOINT: &str = \"https://hub.atuin.sh\";\n\nimpl Default for HubEndpoint {\n    fn default() -> Self {\n        HubEndpoint(DEFAULT_HUB_ENDPOINT.to_string())\n    }\n}\n\nimpl AsRef<str> for HubEndpoint {\n    fn as_ref(&self) -> &str {\n        &self.0\n    }\n}\n\n#[derive(Clone, Debug, Deserialize, Copy, ValueEnum, PartialEq, Serialize)]\npub enum SearchMode {\n    #[serde(rename = \"prefix\")]\n    Prefix,\n\n    #[serde(rename = \"fulltext\")]\n    #[clap(aliases = &[\"fulltext\"])]\n    FullText,\n\n    #[serde(rename = \"fuzzy\")]\n    Fuzzy,\n\n    #[serde(rename = \"skim\")]\n    Skim,\n\n    #[serde(rename = \"daemon-fuzzy\")]\n    #[clap(aliases = &[\"daemon-fuzzy\"])]\n    DaemonFuzzy,\n}\n\nimpl SearchMode {\n    pub fn as_str(&self) -> &'static str {\n        match self {\n            SearchMode::Prefix => \"PREFIX\",\n            SearchMode::FullText => \"FULLTXT\",\n            SearchMode::Fuzzy => \"FUZZY\",\n            SearchMode::Skim => \"SKIM\",\n            SearchMode::DaemonFuzzy => \"DAEMON\",\n        }\n    }\n    pub fn next(&self, settings: &Settings) -> Self {\n        match self {\n            SearchMode::Prefix => SearchMode::FullText,\n            // if the user is using skim, we go to skim\n            SearchMode::FullText if settings.search_mode == SearchMode::Skim => SearchMode::Skim,\n            // if the user is using daemon-fuzzy, we go to daemon-fuzzy\n            SearchMode::FullText if settings.search_mode == SearchMode::DaemonFuzzy => {\n                SearchMode::DaemonFuzzy\n            }\n            // otherwise fuzzy.\n            SearchMode::FullText => SearchMode::Fuzzy,\n            SearchMode::Fuzzy | SearchMode::Skim | SearchMode::DaemonFuzzy => SearchMode::Prefix,\n        }\n    }\n}\n\n#[derive(Clone, Debug, Deserialize, Copy, PartialEq, Eq, ValueEnum, Serialize)]\npub enum FilterMode {\n    #[serde(rename = \"global\")]\n    Global = 0,\n\n    #[serde(rename = \"host\")]\n    Host = 1,\n\n    #[serde(rename = \"session\")]\n    Session = 2,\n\n    #[serde(rename = \"directory\")]\n    Directory = 3,\n\n    #[serde(rename = \"workspace\")]\n    Workspace = 4,\n\n    #[serde(rename = \"session-preload\")]\n    SessionPreload = 5,\n}\n\nimpl FilterMode {\n    pub fn as_str(&self) -> &'static str {\n        match self {\n            FilterMode::Global => \"GLOBAL\",\n            FilterMode::Host => \"HOST\",\n            FilterMode::Session => \"SESSION\",\n            FilterMode::Directory => \"DIRECTORY\",\n            FilterMode::Workspace => \"WORKSPACE\",\n            FilterMode::SessionPreload => \"SESSION+\",\n        }\n    }\n}\n\n#[derive(Clone, Debug, Deserialize, Copy, Serialize)]\npub enum ExitMode {\n    #[serde(rename = \"return-original\")]\n    ReturnOriginal,\n\n    #[serde(rename = \"return-query\")]\n    ReturnQuery,\n}\n\n// FIXME: Can use upstream Dialect enum if https://github.com/stevedonovan/chrono-english/pull/16 is merged\n// FIXME: Above PR was merged, but dependency was changed to interim (fork of chrono-english) in the ... interim\n#[derive(Clone, Debug, Deserialize, Copy, Serialize)]\npub enum Dialect {\n    #[serde(rename = \"us\")]\n    Us,\n\n    #[serde(rename = \"uk\")]\n    Uk,\n}\n\nimpl From<Dialect> for interim::Dialect {\n    fn from(d: Dialect) -> interim::Dialect {\n        match d {\n            Dialect::Uk => interim::Dialect::Uk,\n            Dialect::Us => interim::Dialect::Us,\n        }\n    }\n}\n\n/// Type wrapper around `time::UtcOffset` to support a wider variety of timezone formats.\n///\n/// Note that the parsing of this struct needs to be done before starting any\n/// multithreaded runtime, otherwise it will fail on most Unix systems.\n///\n/// See: <https://github.com/atuinsh/atuin/pull/1517#discussion_r1447516426>\n#[derive(Clone, Copy, Debug, Eq, PartialEq, DeserializeFromStr, Serialize)]\npub struct Timezone(pub UtcOffset);\nimpl fmt::Display for Timezone {\n    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {\n        self.0.fmt(f)\n    }\n}\n/// format: <+|-><hour>[:<minute>[:<second>]]\nstatic OFFSET_FMT: &[FormatItem<'_>] = format_description!(\n    \"[offset_hour sign:mandatory padding:none][optional [:[offset_minute padding:none][optional [:[offset_second padding:none]]]]]\"\n);\nimpl FromStr for Timezone {\n    type Err = Error;\n\n    fn from_str(s: &str) -> Result<Self> {\n        // local timezone\n        if matches!(s.to_lowercase().as_str(), \"l\" | \"local\") {\n            // There have been some timezone issues, related to errors fetching it on some\n            // platforms\n            // Rather than fail to start, fallback to UTC. The user should still be able to specify\n            // their timezone manually in the config file.\n            let offset = UtcOffset::current_local_offset().unwrap_or(UtcOffset::UTC);\n            return Ok(Self(offset));\n        }\n\n        if matches!(s.to_lowercase().as_str(), \"0\" | \"utc\") {\n            let offset = UtcOffset::UTC;\n            return Ok(Self(offset));\n        }\n\n        // offset from UTC\n        if let Ok(offset) = UtcOffset::parse(s, OFFSET_FMT) {\n            return Ok(Self(offset));\n        }\n\n        // IDEA: Currently named timezones are not supported, because the well-known crate\n        // for this is `chrono_tz`, which is not really interoperable with the datetime crate\n        // that we currently use - `time`. If ever we migrate to using `chrono`, this would\n        // be a good feature to add.\n\n        bail!(r#\"\"{s}\" is not a valid timezone spec\"#)\n    }\n}\n\n#[derive(Clone, Debug, Deserialize, Copy, Serialize)]\npub enum Style {\n    #[serde(rename = \"auto\")]\n    Auto,\n\n    #[serde(rename = \"full\")]\n    Full,\n\n    #[serde(rename = \"compact\")]\n    Compact,\n}\n\n#[derive(Clone, Debug, Deserialize, Copy, Serialize)]\npub enum WordJumpMode {\n    #[serde(rename = \"emacs\")]\n    Emacs,\n\n    #[serde(rename = \"subl\")]\n    Subl,\n}\n\n#[derive(Clone, Debug, Deserialize, Copy, PartialEq, Eq, ValueEnum, Serialize)]\npub enum KeymapMode {\n    #[serde(rename = \"emacs\")]\n    Emacs,\n\n    #[serde(rename = \"vim-normal\")]\n    VimNormal,\n\n    #[serde(rename = \"vim-insert\")]\n    VimInsert,\n\n    #[serde(rename = \"auto\")]\n    Auto,\n}\n\nimpl KeymapMode {\n    pub fn as_str(&self) -> &'static str {\n        match self {\n            KeymapMode::Emacs => \"EMACS\",\n            KeymapMode::VimNormal => \"VIMNORMAL\",\n            KeymapMode::VimInsert => \"VIMINSERT\",\n            KeymapMode::Auto => \"AUTO\",\n        }\n    }\n}\n\n// We want to translate the config to crossterm::cursor::SetCursorStyle, but\n// the original type does not implement trait serde::Deserialize unfortunately.\n// It seems impossible to implement Deserialize for external types when it is\n// used in HashMap (https://stackoverflow.com/questions/67142663).  We instead\n// define an adapter type.\n#[derive(Clone, Debug, Deserialize, Copy, PartialEq, Eq, ValueEnum, Serialize)]\npub enum CursorStyle {\n    #[serde(rename = \"default\")]\n    DefaultUserShape,\n\n    #[serde(rename = \"blink-block\")]\n    BlinkingBlock,\n\n    #[serde(rename = \"steady-block\")]\n    SteadyBlock,\n\n    #[serde(rename = \"blink-underline\")]\n    BlinkingUnderScore,\n\n    #[serde(rename = \"steady-underline\")]\n    SteadyUnderScore,\n\n    #[serde(rename = \"blink-bar\")]\n    BlinkingBar,\n\n    #[serde(rename = \"steady-bar\")]\n    SteadyBar,\n}\n\nimpl CursorStyle {\n    pub fn as_str(&self) -> &'static str {\n        match self {\n            CursorStyle::DefaultUserShape => \"DEFAULT\",\n            CursorStyle::BlinkingBlock => \"BLINKBLOCK\",\n            CursorStyle::SteadyBlock => \"STEADYBLOCK\",\n            CursorStyle::BlinkingUnderScore => \"BLINKUNDERLINE\",\n            CursorStyle::SteadyUnderScore => \"STEADYUNDERLINE\",\n            CursorStyle::BlinkingBar => \"BLINKBAR\",\n            CursorStyle::SteadyBar => \"STEADYBAR\",\n        }\n    }\n}\n\n#[derive(Clone, Debug, Deserialize, Serialize)]\npub struct Stats {\n    #[serde(default = \"Stats::common_prefix_default\")]\n    pub common_prefix: Vec<String>, // sudo, etc. commands we want to strip off\n    #[serde(default = \"Stats::common_subcommands_default\")]\n    pub common_subcommands: Vec<String>, // kubectl, commands we should consider subcommands for\n    #[serde(default = \"Stats::ignored_commands_default\")]\n    pub ignored_commands: Vec<String>, // cd, ls, etc. commands we want to completely hide from stats\n}\n\nimpl Stats {\n    fn common_prefix_default() -> Vec<String> {\n        vec![\"sudo\", \"doas\"].into_iter().map(String::from).collect()\n    }\n\n    fn common_subcommands_default() -> Vec<String> {\n        vec![\n            \"apt\",\n            \"cargo\",\n            \"composer\",\n            \"dnf\",\n            \"docker\",\n            \"dotnet\",\n            \"git\",\n            \"go\",\n            \"ip\",\n            \"jj\",\n            \"kubectl\",\n            \"nix\",\n            \"nmcli\",\n            \"npm\",\n            \"pecl\",\n            \"pnpm\",\n            \"podman\",\n            \"port\",\n            \"systemctl\",\n            \"tmux\",\n            \"yarn\",\n        ]\n        .into_iter()\n        .map(String::from)\n        .collect()\n    }\n\n    fn ignored_commands_default() -> Vec<String> {\n        vec![]\n    }\n}\n\nimpl Default for Stats {\n    fn default() -> Self {\n        Self {\n            common_prefix: Self::common_prefix_default(),\n            common_subcommands: Self::common_subcommands_default(),\n            ignored_commands: Self::ignored_commands_default(),\n        }\n    }\n}\n\n#[derive(Clone, Debug, Deserialize, Default, Serialize)]\npub struct Sync {\n    pub records: bool,\n}\n\n/// Sync protocol type for authentication.\n///\n/// This setting is primarily for development/testing. When not explicitly set,\n/// the protocol is inferred from the sync_address:\n/// - Default sync address (api.atuin.sh) → Hub protocol\n/// - Custom sync address → Legacy protocol\n///\n/// Set explicitly to \"hub\" to use Hub authentication with a custom sync_address\n/// (useful for local development against a Hub instance).\n#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq, Serialize, Default)]\n#[serde(rename_all = \"lowercase\")]\npub enum SyncProtocol {\n    /// Use Hub authentication (Bearer token from Hub OAuth flow)\n    Hub,\n    /// Use legacy CLI authentication (Token from CLI register/login)\n    Legacy,\n    /// Infer from sync_address (default behavior)\n    #[default]\n    Auto,\n}\n\n#[derive(Clone, Debug, Deserialize, Default, Serialize)]\npub struct Keys {\n    pub scroll_exits: bool,\n    pub exit_past_line_start: bool,\n    pub accept_past_line_end: bool,\n    pub accept_past_line_start: bool,\n    pub accept_with_backspace: bool,\n    pub prefix: String,\n}\n\nimpl Keys {\n    /// The standard default values for all `[keys]` options.\n    /// These match the config defaults set in `builder_with_data_dir()`.\n    pub fn standard_defaults() -> Self {\n        Keys {\n            scroll_exits: true,\n            exit_past_line_start: true,\n            accept_past_line_end: true,\n            accept_past_line_start: false,\n            accept_with_backspace: false,\n            prefix: \"a\".to_string(),\n        }\n    }\n\n    /// Returns true if any value differs from the standard defaults.\n    pub fn has_non_default_values(&self) -> bool {\n        let d = Self::standard_defaults();\n        self.scroll_exits != d.scroll_exits\n            || self.exit_past_line_start != d.exit_past_line_start\n            || self.accept_past_line_end != d.accept_past_line_end\n            || self.accept_past_line_start != d.accept_past_line_start\n            || self.accept_with_backspace != d.accept_with_backspace\n            || self.prefix != d.prefix\n    }\n}\n\n/// A single rule within a conditional keybinding config.\n#[derive(Clone, Debug, Deserialize, Serialize)]\npub struct KeyRuleConfig {\n    /// Optional condition expression (e.g. \"cursor-at-start\", \"input-empty && no-results\").\n    /// If absent, the rule always matches.\n    #[serde(default)]\n    pub when: Option<String>,\n    /// The action to perform (e.g. \"exit\", \"cursor-left\", \"accept\").\n    pub action: String,\n}\n\n/// A keybinding config value: either a simple action string or an ordered list of conditional rules.\n#[derive(Clone, Debug, Deserialize, Serialize)]\n#[serde(untagged)]\npub enum KeyBindingConfig {\n    /// Simple unconditional binding: `\"ctrl-c\" = \"return-original\"`\n    Simple(String),\n    /// Conditional binding: `\"left\" = [{ when = \"cursor-at-start\", action = \"exit\" }, { action = \"cursor-left\" }]`\n    Rules(Vec<KeyRuleConfig>),\n}\n\n/// User-facing keymap configuration. Each mode maps key strings to bindings.\n/// Keys present here override the defaults for that key; unmentioned keys keep defaults.\n#[derive(Clone, Debug, Deserialize, Serialize, Default)]\npub struct KeymapConfig {\n    #[serde(default)]\n    pub emacs: HashMap<String, KeyBindingConfig>,\n    #[serde(default, rename = \"vim-normal\")]\n    pub vim_normal: HashMap<String, KeyBindingConfig>,\n    #[serde(default, rename = \"vim-insert\")]\n    pub vim_insert: HashMap<String, KeyBindingConfig>,\n    #[serde(default)]\n    pub inspector: HashMap<String, KeyBindingConfig>,\n    #[serde(default)]\n    pub prefix: HashMap<String, KeyBindingConfig>,\n}\n\nimpl KeymapConfig {\n    /// Returns true if no keybinding overrides are configured in any mode.\n    pub fn is_empty(&self) -> bool {\n        self.emacs.is_empty()\n            && self.vim_normal.is_empty()\n            && self.vim_insert.is_empty()\n            && self.inspector.is_empty()\n            && self.prefix.is_empty()\n    }\n}\n\n#[derive(Clone, Debug, Deserialize, Serialize)]\npub struct Preview {\n    pub strategy: PreviewStrategy,\n}\n\n#[derive(Clone, Debug, Deserialize, Serialize)]\npub struct Theme {\n    /// Name of desired theme (\"default\" for base)\n    pub name: String,\n\n    /// Whether any available additional theme debug should be shown\n    pub debug: Option<bool>,\n\n    /// How many levels of parenthood will be traversed if needed\n    pub max_depth: Option<u8>,\n}\n\n#[derive(Clone, Debug, Deserialize, Serialize)]\npub struct Daemon {\n    /// Use the daemon to sync\n    /// If enabled, history hooks are routed through the daemon.\n    #[serde(alias = \"enable\")]\n    pub enabled: bool,\n\n    /// Automatically start and manage a local daemon when needed.\n    pub autostart: bool,\n\n    /// The daemon will handle sync on an interval. How often to sync, in seconds.\n    pub sync_frequency: u64,\n\n    /// The path to the unix socket used by the daemon\n    pub socket_path: String,\n\n    /// Path to the daemon pidfile used for process coordination.\n    pub pidfile_path: String,\n\n    /// Use a socket passed via systemd's socket activation protocol, instead of the path\n    pub systemd_socket: bool,\n\n    /// The port that should be used for TCP on non unix systems\n    pub tcp_port: u64,\n}\n\n#[derive(Clone, Debug, Deserialize, Serialize)]\npub struct Search {\n    /// The list of enabled filter modes, in order of priority.\n    pub filters: Vec<FilterMode>,\n\n    /// The recency score multiplier for the search index (default: 1.0).\n    /// Values < 1.0 reduce weight, > 1.0 increase weight, 0.0 disables.\n    pub recency_score_multiplier: f64,\n\n    /// The frequency score multiplier for the search index (default: 1.0).\n    /// Values < 1.0 reduce weight, > 1.0 increase weight, 0.0 disables.\n    pub frequency_score_multiplier: f64,\n\n    /// The overall frecency score multiplier for the search index (default: 1.0).\n    /// Applied after combining recency and frequency scores.\n    pub frecency_score_multiplier: f64,\n}\n\n#[derive(Clone, Debug, Deserialize, Serialize)]\npub struct Tmux {\n    /// Enable using atuin with tmux popup (tmux >= 3.2)\n    pub enabled: bool,\n\n    /// Width of the tmux popup (percentage)\n    pub width: String,\n\n    /// Height of the tmux popup (percentage)\n    pub height: String,\n}\n\n/// Log level for file logging. Maps to tracing's LevelFilter.\n#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize, Serialize)]\n#[serde(rename_all = \"lowercase\")]\npub enum LogLevel {\n    Trace,\n    Debug,\n    #[default]\n    Info,\n    Warn,\n    Error,\n}\n\nimpl LogLevel {\n    /// Convert to a tracing directive string for use with EnvFilter.\n    pub fn as_directive(&self) -> &'static str {\n        match self {\n            LogLevel::Trace => \"trace\",\n            LogLevel::Debug => \"debug\",\n            LogLevel::Info => \"info\",\n            LogLevel::Warn => \"warn\",\n            LogLevel::Error => \"error\",\n        }\n    }\n}\n\n/// Configuration for a specific log type (search or daemon).\n#[derive(Clone, Debug, Default, Deserialize, Serialize)]\npub struct LogConfig {\n    /// Log file name (relative to dir) or absolute path.\n    pub file: String,\n\n    /// Override global enabled setting for this log type.\n    pub enabled: Option<bool>,\n\n    /// Override global level setting for this log type.\n    pub level: Option<LogLevel>,\n\n    /// Override global retention days setting for this log type.\n    pub retention: Option<u64>,\n}\n\n#[derive(Clone, Debug, Deserialize, Serialize)]\npub struct Logs {\n    /// Enable file logging globally. Defaults to true.\n    #[serde(default = \"Logs::default_enabled\")]\n    pub enabled: bool,\n\n    /// Directory for log files. Defaults to ~/.atuin/logs\n    pub dir: String,\n\n    /// Default log level for file logging. Defaults to \"info\".\n    /// Note: ATUIN_LOG environment variable overrides this.\n    #[serde(default)]\n    pub level: LogLevel,\n\n    /// Default retention days for log files. Defaults to 4.\n    #[serde(default = \"Logs::default_retention\")]\n    pub retention: u64,\n\n    /// Search log settings\n    #[serde(default)]\n    pub search: LogConfig,\n\n    /// Daemon log settings\n    #[serde(default)]\n    pub daemon: LogConfig,\n\n    /// AI log settings\n    #[serde(default)]\n    pub ai: LogConfig,\n}\n\n#[derive(Default, Clone, Debug, Deserialize, Serialize)]\npub struct Ai {\n    /// Whether or not the AI features are enabled.\n    pub enabled: bool,\n\n    /// The address of the Atuin AI endpoint. Used for AI features like command generation.\n    /// Only necessary for custom AI endpoints.\n    pub endpoint: Option<String>,\n\n    /// The API token for the Atuin AI endpoint. Used for AI features like command generation.\n    /// Only necessary for custom AI endpoints.\n    pub api_token: Option<String>,\n\n    /// Whether or not to send the current working directory to the AI endpoint.\n    pub send_cwd: bool,\n}\n\nimpl Default for Preview {\n    fn default() -> Self {\n        Self {\n            strategy: PreviewStrategy::Auto,\n        }\n    }\n}\n\nimpl Default for Theme {\n    fn default() -> Self {\n        Self {\n            name: \"\".to_string(),\n            debug: None::<bool>,\n            max_depth: Some(10),\n        }\n    }\n}\n\nimpl Default for Daemon {\n    fn default() -> Self {\n        Self {\n            enabled: false,\n            autostart: false,\n            sync_frequency: 300,\n            socket_path: \"\".to_string(),\n            pidfile_path: \"\".to_string(),\n            systemd_socket: false,\n            tcp_port: 8889,\n        }\n    }\n}\n\nimpl Default for Logs {\n    fn default() -> Self {\n        Self {\n            enabled: true,\n            dir: \"\".to_string(),\n            level: LogLevel::default(),\n            retention: Self::default_retention(),\n            search: LogConfig {\n                file: \"search.log\".to_string(),\n                ..Default::default()\n            },\n            daemon: LogConfig {\n                file: \"daemon.log\".to_string(),\n                ..Default::default()\n            },\n            ai: LogConfig {\n                file: \"ai.log\".to_string(),\n                ..Default::default()\n            },\n        }\n    }\n}\n\nimpl Logs {\n    fn default_enabled() -> bool {\n        true\n    }\n\n    fn default_retention() -> u64 {\n        4\n    }\n\n    /// Returns whether search logging is enabled.\n    /// Uses search-specific setting if set, otherwise falls back to global.\n    pub fn search_enabled(&self) -> bool {\n        self.search.enabled.unwrap_or(self.enabled)\n    }\n\n    /// Returns whether daemon logging is enabled.\n    /// Uses daemon-specific setting if set, otherwise falls back to global.\n    pub fn daemon_enabled(&self) -> bool {\n        self.daemon.enabled.unwrap_or(self.enabled)\n    }\n\n    /// Returns whether AI logging is enabled.\n    /// Uses AI-specific setting if set, otherwise falls back to global.\n    pub fn ai_enabled(&self) -> bool {\n        self.ai.enabled.unwrap_or(self.enabled)\n    }\n\n    /// Returns the log level for search logging.\n    /// Uses search-specific setting if set, otherwise falls back to global.\n    pub fn search_level(&self) -> LogLevel {\n        self.search.level.unwrap_or(self.level)\n    }\n\n    /// Returns the log level for daemon logging.\n    /// Uses daemon-specific setting if set, otherwise falls back to global.\n    pub fn daemon_level(&self) -> LogLevel {\n        self.daemon.level.unwrap_or(self.level)\n    }\n\n    /// Returns the log level for AI logging.\n    /// Uses AI-specific setting if set, otherwise falls back to global.\n    pub fn ai_level(&self) -> LogLevel {\n        self.ai.level.unwrap_or(self.level)\n    }\n\n    /// Returns the retention days for search logging.\n    /// Uses search-specific setting if set, otherwise falls back to global.\n    pub fn search_retention(&self) -> u64 {\n        self.search.retention.unwrap_or(self.retention)\n    }\n\n    /// Returns the retention days for daemon logging.\n    /// Uses daemon-specific setting if set, otherwise falls back to global.\n    pub fn daemon_retention(&self) -> u64 {\n        self.daemon.retention.unwrap_or(self.retention)\n    }\n\n    /// Returns the retention days for AI logging.\n    /// Uses AI-specific setting if set, otherwise falls back to global.\n    pub fn ai_retention(&self) -> u64 {\n        self.ai.retention.unwrap_or(self.retention)\n    }\n\n    /// Returns the full path for the search log file.\n    pub fn search_path(&self) -> PathBuf {\n        let path = PathBuf::from(&self.search.file);\n        PathBuf::from(&self.dir).join(path)\n    }\n\n    /// Returns the full path for the daemon log file.\n    pub fn daemon_path(&self) -> PathBuf {\n        let path = PathBuf::from(&self.daemon.file);\n        PathBuf::from(&self.dir).join(path)\n    }\n\n    /// Returns the full path for the AI log file.\n    pub fn ai_path(&self) -> PathBuf {\n        let path = PathBuf::from(&self.ai.file);\n        PathBuf::from(&self.dir).join(path)\n    }\n}\n\nimpl Default for Search {\n    fn default() -> Self {\n        Self {\n            filters: vec![\n                FilterMode::Global,\n                FilterMode::Host,\n                FilterMode::Session,\n                FilterMode::SessionPreload,\n                FilterMode::Workspace,\n                FilterMode::Directory,\n            ],\n\n            recency_score_multiplier: 1.0,\n            frequency_score_multiplier: 1.0,\n            frecency_score_multiplier: 1.0,\n        }\n    }\n}\n\nimpl Default for Tmux {\n    fn default() -> Self {\n        Self {\n            enabled: false,\n            width: \"80%\".to_string(),\n            height: \"60%\".to_string(),\n        }\n    }\n}\n\n// The preview height strategy also takes max_preview_height into account.\n#[derive(Clone, Debug, Deserialize, Copy, PartialEq, Eq, ValueEnum, Serialize)]\npub enum PreviewStrategy {\n    // Preview height is calculated for the length of the selected command.\n    #[serde(rename = \"auto\")]\n    Auto,\n\n    // Preview height is calculated for the length of the longest command stored in the history.\n    #[serde(rename = \"static\")]\n    Static,\n\n    // max_preview_height is used as fixed height.\n    #[serde(rename = \"fixed\")]\n    Fixed,\n}\n\n/// Column types available for the interactive search UI.\n#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq, Serialize)]\n#[serde(rename_all = \"lowercase\")]\npub enum UiColumnType {\n    /// Command execution duration (e.g., \"123ms\")\n    Duration,\n    /// Relative time since execution (e.g., \"59s ago\")\n    Time,\n    /// Absolute timestamp (e.g., \"2025-01-22 14:35\")\n    Datetime,\n    /// Working directory\n    Directory,\n    /// Hostname\n    Host,\n    /// Username\n    User,\n    /// Exit code\n    Exit,\n    /// The command itself (should be last, expands to fill)\n    Command,\n}\n\nimpl UiColumnType {\n    /// Returns the default width for this column type (in characters).\n    /// The Command column returns 0 as it expands to fill remaining space.\n    pub fn default_width(&self) -> u16 {\n        match self {\n            UiColumnType::Duration => 5,  // \"814ms\"\n            UiColumnType::Time => 9,      // \"459ms ago\"\n            UiColumnType::Datetime => 16, // \"2025-01-22 14:35\"\n            UiColumnType::Directory => 20,\n            UiColumnType::Host => 15,\n            UiColumnType::User => 10,\n            UiColumnType::Exit => {\n                if cfg!(windows) {\n                    11 // 32-bit integer on Windows: \"-1978335212\"\n                } else {\n                    3 // Usually a byte on Unix\n                }\n            }\n            UiColumnType::Command => 0, // Expands to fill\n        }\n    }\n}\n\n/// A column configuration with type and optional custom width.\n/// Can be specified as just a string (uses default width) or as an object with type and width.\n#[derive(Clone, Debug, Serialize)]\npub struct UiColumn {\n    pub column_type: UiColumnType,\n    pub width: u16,\n    /// If true, this column expands to fill remaining space. Only one column should expand.\n    pub expand: bool,\n}\n\nimpl UiColumn {\n    pub fn new(column_type: UiColumnType) -> Self {\n        Self {\n            width: column_type.default_width(),\n            expand: column_type == UiColumnType::Command,\n            column_type,\n        }\n    }\n\n    pub fn with_width(column_type: UiColumnType, width: u16) -> Self {\n        Self {\n            column_type,\n            width,\n            expand: column_type == UiColumnType::Command,\n        }\n    }\n}\n\n// Custom deserialize to handle both string and object formats:\n// \"duration\" or { type = \"duration\", width = 8, expand = true }\nimpl<'de> serde::Deserialize<'de> for UiColumn {\n    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>\n    where\n        D: serde::Deserializer<'de>,\n    {\n        use serde::de::{self, MapAccess, Visitor};\n\n        struct UiColumnVisitor;\n\n        impl<'de> Visitor<'de> for UiColumnVisitor {\n            type Value = UiColumn;\n\n            fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {\n                formatter.write_str(\n                    \"a column type string or an object with 'type' and optional 'width'/'expand'\",\n                )\n            }\n\n            fn visit_str<E>(self, value: &str) -> Result<UiColumn, E>\n            where\n                E: de::Error,\n            {\n                let column_type: UiColumnType =\n                    serde::Deserialize::deserialize(serde::de::value::StrDeserializer::new(value))?;\n                Ok(UiColumn::new(column_type))\n            }\n\n            fn visit_map<M>(self, mut map: M) -> Result<UiColumn, M::Error>\n            where\n                M: MapAccess<'de>,\n            {\n                let mut column_type: Option<UiColumnType> = None;\n                let mut width: Option<u16> = None;\n                let mut expand: Option<bool> = None;\n\n                while let Some(key) = map.next_key::<String>()? {\n                    match key.as_str() {\n                        \"type\" => {\n                            column_type = Some(map.next_value()?);\n                        }\n                        \"width\" => {\n                            width = Some(map.next_value()?);\n                        }\n                        \"expand\" => {\n                            expand = Some(map.next_value()?);\n                        }\n                        _ => {\n                            let _: serde::de::IgnoredAny = map.next_value()?;\n                        }\n                    }\n                }\n\n                let column_type = column_type.ok_or_else(|| de::Error::missing_field(\"type\"))?;\n                let width = width.unwrap_or_else(|| column_type.default_width());\n                let expand = expand.unwrap_or(column_type == UiColumnType::Command);\n                Ok(UiColumn {\n                    column_type,\n                    width,\n                    expand,\n                })\n            }\n        }\n\n        deserializer.deserialize_any(UiColumnVisitor)\n    }\n}\n\n/// UI-specific settings for the interactive search.\n#[derive(Clone, Debug, Deserialize, Serialize)]\npub struct Ui {\n    /// Columns to display in interactive search, from left to right.\n    /// The indicator column (\" > \") is always shown first implicitly.\n    /// The \"command\" column should be last as it expands to fill remaining space.\n    /// Can be simple strings or objects with type and width.\n    #[serde(default = \"Ui::default_columns\")]\n    pub columns: Vec<UiColumn>,\n}\n\nimpl Ui {\n    fn default_columns() -> Vec<UiColumn> {\n        vec![\n            UiColumn::new(UiColumnType::Duration),\n            UiColumn::new(UiColumnType::Time),\n            UiColumn::new(UiColumnType::Command),\n        ]\n    }\n\n    /// Validate the UI configuration.\n    /// Returns an error if more than one column has expand = true.\n    pub fn validate(&self) -> Result<()> {\n        let expand_count = self.columns.iter().filter(|c| c.expand).count();\n        if expand_count > 1 {\n            bail!(\n                \"Only one column can have expand = true, but {} columns are set to expand\",\n                expand_count\n            );\n        }\n        Ok(())\n    }\n}\n\nimpl Default for Ui {\n    fn default() -> Self {\n        Self {\n            columns: Self::default_columns(),\n        }\n    }\n}\n\n#[derive(Clone, Debug, Deserialize, Serialize)]\npub struct Settings {\n    pub data_dir: Option<String>,\n    pub dialect: Dialect,\n    pub timezone: Timezone,\n    pub style: Style,\n    pub auto_sync: bool,\n    pub update_check: bool,\n\n    /// The sync address for atuin.\n    pub sync_address: String,\n\n    /// Sync protocol for authentication. When set to \"auto\" (default), the protocol\n    /// is inferred from sync_address. Set to \"hub\" to force Hub auth with a custom\n    /// sync_address (useful for local development).\n    #[serde(default)]\n    pub sync_protocol: SyncProtocol,\n\n    pub sync_frequency: String,\n    pub db_path: String,\n    pub record_store_path: String,\n    pub key_path: String,\n    pub search_mode: SearchMode,\n    pub filter_mode: Option<FilterMode>,\n    pub filter_mode_shell_up_key_binding: Option<FilterMode>,\n    pub search_mode_shell_up_key_binding: Option<SearchMode>,\n    pub shell_up_key_binding: bool,\n    pub inline_height: u16,\n    pub inline_height_shell_up_key_binding: Option<u16>,\n    pub invert: bool,\n    pub show_preview: bool,\n    pub max_preview_height: u16,\n    pub show_help: bool,\n    pub show_tabs: bool,\n    pub show_numeric_shortcuts: bool,\n    pub auto_hide_height: u16,\n    pub exit_mode: ExitMode,\n    pub keymap_mode: KeymapMode,\n    pub keymap_mode_shell: KeymapMode,\n    pub keymap_cursor: HashMap<String, CursorStyle>,\n    pub word_jump_mode: WordJumpMode,\n    pub word_chars: String,\n    pub scroll_context_lines: usize,\n    pub history_format: String,\n    pub prefers_reduced_motion: bool,\n    pub store_failed: bool,\n\n    #[serde(with = \"serde_regex\", default = \"RegexSet::empty\", skip_serializing)]\n    pub history_filter: RegexSet,\n\n    #[serde(with = \"serde_regex\", default = \"RegexSet::empty\", skip_serializing)]\n    pub cwd_filter: RegexSet,\n\n    pub secrets_filter: bool,\n    pub workspaces: bool,\n    pub ctrl_n_shortcuts: bool,\n\n    pub network_connect_timeout: u64,\n    pub network_timeout: u64,\n    pub local_timeout: f64,\n    pub enter_accept: bool,\n    pub smart_sort: bool,\n    pub command_chaining: bool,\n\n    #[serde(default)]\n    pub stats: Stats,\n\n    #[serde(default)]\n    pub sync: Sync,\n\n    #[serde(default)]\n    pub keys: Keys,\n\n    #[serde(default)]\n    pub keymap: KeymapConfig,\n\n    #[serde(default)]\n    pub preview: Preview,\n\n    #[serde(default)]\n    pub dotfiles: dotfiles::Settings,\n\n    #[serde(default)]\n    pub daemon: Daemon,\n\n    #[serde(default)]\n    pub search: Search,\n\n    #[serde(default)]\n    pub theme: Theme,\n\n    #[serde(default)]\n    pub ui: Ui,\n\n    #[serde(default)]\n    pub scripts: scripts::Settings,\n\n    #[serde(default)]\n    pub kv: kv::Settings,\n\n    #[serde(default)]\n    pub tmux: Tmux,\n\n    #[serde(default)]\n    pub logs: Logs,\n\n    #[serde(default)]\n    pub meta: meta::Settings,\n\n    #[serde(default)]\n    pub ai: Ai,\n}\n\nimpl Settings {\n    pub fn utc() -> Self {\n        Self::builder()\n            .expect(\"Could not build default\")\n            .set_override(\"timezone\", \"0\")\n            .expect(\"failed to override timezone with UTC\")\n            .build()\n            .expect(\"Could not build config\")\n            .try_deserialize()\n            .expect(\"Could not deserialize config\")\n    }\n\n    pub(crate) fn effective_data_dir() -> PathBuf {\n        DATA_DIR\n            .get()\n            .cloned()\n            .unwrap_or_else(atuin_common::utils::data_dir)\n    }\n\n    // -- Meta store: lazily initialized on first access --\n\n    pub async fn meta_store() -> Result<&'static crate::meta::MetaStore> {\n        META_STORE\n            .get_or_try_init(|| async {\n                let (db_path, timeout) = META_CONFIG.get().ok_or_else(|| {\n                    eyre!(\"meta store config not set — Settings::new() has not been called\")\n                })?;\n                crate::meta::MetaStore::new(db_path, *timeout).await\n            })\n            .await\n    }\n\n    pub async fn host_id() -> Result<HostId> {\n        Self::meta_store().await?.host_id().await\n    }\n\n    pub async fn last_sync() -> Result<OffsetDateTime> {\n        Self::meta_store().await?.last_sync().await\n    }\n\n    pub async fn save_sync_time() -> Result<()> {\n        Self::meta_store().await?.save_sync_time().await\n    }\n\n    pub async fn last_version_check() -> Result<OffsetDateTime> {\n        Self::meta_store().await?.last_version_check().await\n    }\n\n    pub async fn save_version_check_time() -> Result<()> {\n        Self::meta_store().await?.save_version_check_time().await\n    }\n\n    pub async fn should_sync(&self) -> Result<bool> {\n        if !self.auto_sync || !Self::meta_store().await?.logged_in().await? {\n            return Ok(false);\n        }\n\n        if self.sync_frequency == \"0\" {\n            return Ok(true);\n        }\n\n        match parse_duration(self.sync_frequency.as_str()) {\n            Ok(d) => {\n                let d = time::Duration::try_from(d)?;\n                Ok(OffsetDateTime::now_utc() - Settings::last_sync().await? >= d)\n            }\n            Err(e) => Err(eyre!(\"failed to check sync: {}\", e)),\n        }\n    }\n\n    pub async fn logged_in(&self) -> Result<bool> {\n        Self::meta_store().await?.logged_in().await\n    }\n\n    pub async fn session_token(&self) -> Result<String> {\n        match Self::meta_store().await?.session_token().await? {\n            Some(token) => Ok(token),\n            None => Err(eyre!(\"Tried to load session; not logged in\")),\n        }\n    }\n\n    pub async fn hub_session_token(&self) -> Result<String> {\n        match Self::meta_store().await?.hub_session_token().await? {\n            Some(token) => Ok(token),\n            None => Err(eyre!(\"Tried to load hub session; not logged in\")),\n        }\n    }\n\n    /// Normalize a URL for comparison by trimming trailing slashes\n    fn normalize_url(url: &str) -> &str {\n        url.trim_end_matches('/')\n    }\n\n    /// Check if a URL matches one of Atuin's official hosted addresses\n    fn is_official_address(url: &str) -> bool {\n        let normalized = Self::normalize_url(url);\n        normalized == Self::normalize_url(DEFAULT_SYNC_ADDRESS)\n            || normalized == Self::normalize_url(DEFAULT_HUB_ENDPOINT)\n    }\n\n    /// Returns whether this configuration uses Hub-style sync.\n    ///\n    /// Hub sync uses Bearer token authentication and is the default for\n    /// Atuin's hosted service. This returns true when:\n    /// - `sync_protocol` is explicitly set to `Hub`, OR\n    /// - `sync_protocol` is `Auto` and `sync_address` is an official Atuin address\n    pub fn is_hub_sync(&self) -> bool {\n        match self.sync_protocol {\n            SyncProtocol::Hub => true,\n            SyncProtocol::Legacy => false,\n            SyncProtocol::Auto => Self::is_official_address(&self.sync_address),\n        }\n    }\n\n    /// Returns the base URL for the Hub endpoint.\n    ///\n    /// For Atuin's official hosted service, this always returns `https://hub.atuin.sh`\n    /// regardless of whether `sync_address` is `api.atuin.sh` or `hub.atuin.sh`.\n    /// For self-hosted instances, returns the configured `sync_address`.\n    pub fn active_hub_endpoint(&self) -> Option<HubEndpoint> {\n        if self.is_hub_sync() {\n            if Self::is_official_address(&self.sync_address) {\n                Some(HubEndpoint::default())\n            } else {\n                Some(HubEndpoint(self.sync_address.clone()))\n            }\n        } else {\n            None\n        }\n    }\n\n    /// Returns the best available auth token for sync operations.\n    ///\n    /// Token priority when using Hub sync:\n    /// 1. Hub token (Bearer) - enables unified Hub auth\n    /// 2. CLI session token (Token) - fallback if Hub token revoked\n    ///\n    /// For legacy/self-hosted sync, only CLI session token is used.\n    ///\n    /// Hub tokens are preferred when available because they provide unified\n    /// authentication across CLI and Hub features, and users can manage them\n    /// via the Hub web interface.\n    #[cfg(feature = \"sync\")]\n    pub async fn sync_auth_token(&self) -> Result<crate::api_client::AuthToken> {\n        use crate::api_client::AuthToken;\n\n        let meta = Self::meta_store().await?;\n\n        // Try Hub token first if we're using Hub sync\n        if self.is_hub_sync()\n            && let Some(hub_token) = meta.hub_session_token().await?\n        {\n            return Ok(AuthToken::Bearer(hub_token));\n        }\n\n        // Fall back to CLI session token\n        match meta.session_token().await? {\n            Some(token) => Ok(AuthToken::Token(token)),\n            None => Err(eyre!(\n                \"Not logged in - no Hub session or CLI session found. \\\n                 Run 'atuin login' or 'atuin register' to authenticate.\"\n            )),\n        }\n    }\n\n    #[cfg(feature = \"check-update\")]\n    async fn needs_update_check(&self) -> Result<bool> {\n        let last_check = Settings::last_version_check().await?;\n        let diff = OffsetDateTime::now_utc() - last_check;\n\n        // Check a max of once per hour\n        Ok(diff.whole_hours() >= 1)\n    }\n\n    #[cfg(feature = \"check-update\")]\n    async fn latest_version(&self) -> Result<Version> {\n        // Default to the current version, and if that doesn't parse, a version so high it's unlikely to ever\n        // suggest upgrading.\n        let current =\n            Version::parse(env!(\"CARGO_PKG_VERSION\")).unwrap_or(Version::new(100000, 0, 0));\n\n        if !self.needs_update_check().await? {\n            let meta = Self::meta_store().await?;\n            let version = match meta.latest_version().await? {\n                Some(v) => Version::parse(&v).unwrap_or(current),\n                None => current,\n            };\n\n            return Ok(version);\n        }\n\n        #[cfg(feature = \"sync\")]\n        let latest = crate::api_client::latest_version().await.unwrap_or(current);\n\n        #[cfg(not(feature = \"sync\"))]\n        let latest = current;\n\n        let meta = Self::meta_store().await?;\n        Settings::save_version_check_time().await?;\n        meta.save_latest_version(&latest.to_string()).await?;\n\n        Ok(latest)\n    }\n\n    // Return Some(latest version) if an update is needed. Otherwise, none.\n    #[cfg(feature = \"check-update\")]\n    pub async fn needs_update(&self) -> Option<Version> {\n        if !self.update_check {\n            return None;\n        }\n\n        let current =\n            Version::parse(env!(\"CARGO_PKG_VERSION\")).unwrap_or(Version::new(100000, 0, 0));\n\n        let latest = self.latest_version().await;\n\n        if latest.is_err() {\n            return None;\n        }\n\n        let latest = latest.unwrap();\n\n        if latest > current {\n            return Some(latest);\n        }\n\n        None\n    }\n\n    pub fn default_filter_mode(&self, git_root: bool) -> FilterMode {\n        self.filter_mode\n            .filter(|x| self.search.filters.contains(x))\n            .or_else(|| {\n                self.search\n                    .filters\n                    .iter()\n                    .find(|x| match (x, git_root, self.workspaces) {\n                        (FilterMode::Workspace, true, true) => true,\n                        (FilterMode::Workspace, _, _) => false,\n                        (_, _, _) => true,\n                    })\n                    .copied()\n            })\n            .unwrap_or(FilterMode::Global)\n    }\n\n    #[cfg(not(feature = \"check-update\"))]\n    pub async fn needs_update(&self) -> Option<Version> {\n        None\n    }\n\n    pub fn builder() -> Result<ConfigBuilder<DefaultState>> {\n        Self::builder_with_data_dir(&atuin_common::utils::data_dir())\n    }\n\n    fn builder_with_data_dir(data_dir: &std::path::Path) -> Result<ConfigBuilder<DefaultState>> {\n        let db_path = data_dir.join(\"history.db\");\n        let record_store_path = data_dir.join(\"records.db\");\n        let kv_path = data_dir.join(\"kv.db\");\n        let scripts_path = data_dir.join(\"scripts.db\");\n        let socket_path = atuin_common::utils::runtime_dir().join(\"atuin.sock\");\n        let pidfile_path = data_dir.join(\"atuin-daemon.pid\");\n        let logs_dir = atuin_common::utils::logs_dir();\n\n        let key_path = data_dir.join(\"key\");\n        let meta_path = data_dir.join(\"meta.db\");\n\n        Ok(Config::builder()\n            .set_default(\"history_format\", \"{time}\\t{command}\\t{duration}\")?\n            .set_default(\"db_path\", db_path.to_str())?\n            .set_default(\"record_store_path\", record_store_path.to_str())?\n            .set_default(\"key_path\", key_path.to_str())?\n            .set_default(\"dialect\", \"us\")?\n            .set_default(\"timezone\", \"local\")?\n            .set_default(\"auto_sync\", true)?\n            .set_default(\"update_check\", cfg!(feature = \"check-update\"))?\n            .set_default(\"sync_address\", \"https://api.atuin.sh\")?\n            .set_default(\"sync_frequency\", \"5m\")?\n            .set_default(\"search_mode\", \"fuzzy\")?\n            .set_default(\"filter_mode\", None::<String>)?\n            .set_default(\"style\", \"compact\")?\n            .set_default(\"inline_height\", 40)?\n            .set_default(\"show_preview\", true)?\n            .set_default(\"preview.strategy\", \"auto\")?\n            .set_default(\"max_preview_height\", 4)?\n            .set_default(\"show_help\", true)?\n            .set_default(\"show_tabs\", true)?\n            .set_default(\"show_numeric_shortcuts\", true)?\n            .set_default(\"auto_hide_height\", 8)?\n            .set_default(\"invert\", false)?\n            .set_default(\"exit_mode\", \"return-original\")?\n            .set_default(\"word_jump_mode\", \"emacs\")?\n            .set_default(\n                \"word_chars\",\n                \"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789\",\n            )?\n            .set_default(\"scroll_context_lines\", 1)?\n            .set_default(\"shell_up_key_binding\", false)?\n            .set_default(\"workspaces\", false)?\n            .set_default(\"ctrl_n_shortcuts\", false)?\n            .set_default(\"secrets_filter\", true)?\n            .set_default(\"network_connect_timeout\", 5)?\n            .set_default(\"network_timeout\", 30)?\n            .set_default(\"local_timeout\", 2.0)?\n            // enter_accept defaults to false here, but true in the default config file. The dissonance is\n            // intentional!\n            // Existing users will get the default \"False\", so we don't mess with any potential\n            // muscle memory.\n            // New users will get the new default, that is more similar to what they are used to.\n            .set_default(\"enter_accept\", false)?\n            .set_default(\"sync.records\", true)?\n            .set_default(\"keys.scroll_exits\", true)?\n            .set_default(\"keys.accept_past_line_end\", true)?\n            .set_default(\"keys.exit_past_line_start\", true)?\n            .set_default(\"keys.accept_past_line_start\", false)?\n            .set_default(\"keys.accept_with_backspace\", false)?\n            .set_default(\"keys.prefix\", \"a\")?\n            .set_default(\"keymap_mode\", \"emacs\")?\n            .set_default(\"keymap_mode_shell\", \"auto\")?\n            .set_default(\"keymap_cursor\", HashMap::<String, String>::new())?\n            .set_default(\"smart_sort\", false)?\n            .set_default(\"command_chaining\", false)?\n            .set_default(\"store_failed\", true)?\n            .set_default(\"daemon.sync_frequency\", 300)?\n            .set_default(\"daemon.enabled\", false)?\n            .set_default(\"daemon.autostart\", false)?\n            .set_default(\"daemon.socket_path\", socket_path.to_str())?\n            .set_default(\"daemon.pidfile_path\", pidfile_path.to_str())?\n            .set_default(\"daemon.systemd_socket\", false)?\n            .set_default(\"daemon.tcp_port\", 8889)?\n            .set_default(\"logs.enabled\", true)?\n            .set_default(\"logs.dir\", logs_dir.to_str())?\n            .set_default(\"logs.level\", \"info\")?\n            .set_default(\"logs.search.file\", \"search.log\")?\n            .set_default(\"logs.daemon.file\", \"daemon.log\")?\n            .set_default(\"logs.ai.file\", \"ai.log\")?\n            .set_default(\"kv.db_path\", kv_path.to_str())?\n            .set_default(\"scripts.db_path\", scripts_path.to_str())?\n            .set_default(\"search.recency_score_multiplier\", 1.0)?\n            .set_default(\"search.frequency_score_multiplier\", 1.0)?\n            .set_default(\"search.frecency_score_multiplier\", 1.0)?\n            .set_default(\"meta.db_path\", meta_path.to_str())?\n            .set_default(\"ai.enabled\", false)?\n            .set_default(\"ai.send_cwd\", false)?\n            .set_default(\n                \"search.filters\",\n                vec![\n                    \"global\",\n                    \"host\",\n                    \"session\",\n                    \"workspace\",\n                    \"directory\",\n                    \"session-preload\",\n                ],\n            )?\n            .set_default(\"theme.name\", \"default\")?\n            .set_default(\"theme.debug\", None::<bool>)?\n            .set_default(\"tmux.enabled\", false)?\n            .set_default(\"tmux.width\", \"80%\")?\n            .set_default(\"tmux.height\", \"60%\")?\n            .set_default(\n                \"prefers_reduced_motion\",\n                std::env::var(\"NO_MOTION\")\n                    .ok()\n                    .map(|_| config::Value::new(None, config::ValueKind::Boolean(true)))\n                    .unwrap_or_else(|| config::Value::new(None, config::ValueKind::Boolean(false))),\n            )?\n            .add_source(\n                Environment::with_prefix(\"atuin\")\n                    .prefix_separator(\"_\")\n                    .separator(\"__\"),\n            ))\n    }\n\n    pub fn get_config_path() -> Result<PathBuf> {\n        let config_dir = atuin_common::utils::config_dir();\n\n        create_dir_all(&config_dir)\n            .wrap_err_with(|| format!(\"could not create dir {config_dir:?}\"))?;\n\n        let mut config_file = if let Ok(p) = std::env::var(\"ATUIN_CONFIG_DIR\") {\n            PathBuf::from(p)\n        } else {\n            let mut config_file = PathBuf::new();\n            config_file.push(config_dir);\n            config_file\n        };\n\n        config_file.push(\"config.toml\");\n\n        Ok(config_file)\n    }\n\n    pub fn new() -> Result<Self> {\n        let config_file = Self::get_config_path()?;\n\n        // extract data_dir first so we can use it as the base for other path defaults\n        let effective_data_dir = if config_file.exists() {\n            #[derive(Deserialize, Default)]\n            struct DataDirOnly {\n                data_dir: Option<String>,\n            }\n\n            let config_file_str = config_file\n                .to_str()\n                .ok_or_else(|| eyre!(\"config file path is not valid UTF-8\"))?;\n\n            let partial_config = Config::builder()\n                .add_source(ConfigFile::new(config_file_str, FileFormat::Toml))\n                .add_source(\n                    Environment::with_prefix(\"atuin\")\n                        .prefix_separator(\"_\")\n                        .separator(\"__\"),\n                )\n                .build()\n                .ok();\n\n            let custom_data_dir = partial_config\n                .and_then(|c| c.try_deserialize::<DataDirOnly>().ok())\n                .and_then(|d| d.data_dir);\n\n            match custom_data_dir {\n                Some(dir) => {\n                    let expanded = shellexpand::full(&dir)\n                        .map_err(|e| eyre!(\"failed to expand data_dir path: {}\", e))?;\n                    PathBuf::from(expanded.as_ref())\n                }\n                None => atuin_common::utils::data_dir(),\n            }\n        } else {\n            atuin_common::utils::data_dir()\n        };\n\n        DATA_DIR.set(effective_data_dir.clone()).ok();\n\n        create_dir_all(&effective_data_dir)\n            .wrap_err_with(|| format!(\"could not create dir {effective_data_dir:?}\"))?;\n\n        let mut config_builder = Self::builder_with_data_dir(&effective_data_dir)?;\n\n        config_builder = if config_file.exists() {\n            let config_file_str = config_file\n                .to_str()\n                .ok_or_else(|| eyre!(\"config file path is not valid UTF-8\"))?;\n            config_builder.add_source(ConfigFile::new(config_file_str, FileFormat::Toml))\n        } else {\n            let mut file = File::create(config_file).wrap_err(\"could not create config file\")?;\n            file.write_all(EXAMPLE_CONFIG.as_bytes())\n                .wrap_err(\"could not write default config file\")?;\n\n            config_builder\n        };\n\n        let config = config_builder.build()?;\n        let mut settings: Settings = config\n            .try_deserialize()\n            .map_err(|e| eyre!(\"failed to deserialize: {}\", e))?;\n\n        // all paths should be expanded\n        settings.db_path = Self::expand_path(settings.db_path)?;\n        settings.record_store_path = Self::expand_path(settings.record_store_path)?;\n        settings.key_path = Self::expand_path(settings.key_path)?;\n        settings.daemon.socket_path = Self::expand_path(settings.daemon.socket_path)?;\n        settings.daemon.pidfile_path = Self::expand_path(settings.daemon.pidfile_path)?;\n        settings.logs.dir = Self::expand_path(settings.logs.dir)?;\n        settings.logs.search.file = Self::expand_path(settings.logs.search.file)?;\n        settings.logs.daemon.file = Self::expand_path(settings.logs.daemon.file)?;\n\n        // Validate UI settings\n        settings.ui.validate()?;\n\n        // Register meta store config for lazy initialization on first access\n        META_CONFIG\n            .set((settings.meta.db_path.clone(), settings.local_timeout))\n            .ok();\n\n        Ok(settings)\n    }\n\n    fn expand_path(path: String) -> Result<String> {\n        shellexpand::full(&path)\n            .map(|p| p.to_string())\n            .map_err(|e| eyre!(\"failed to expand path: {}\", e))\n    }\n\n    pub fn example_config() -> &'static str {\n        EXAMPLE_CONFIG\n    }\n\n    pub fn paths_ok(&self) -> bool {\n        let paths = [\n            &self.db_path,\n            &self.record_store_path,\n            &self.key_path,\n            &self.meta.db_path,\n        ];\n        paths.iter().all(|p| !utils::broken_symlink(p))\n    }\n}\n\nimpl Default for Settings {\n    fn default() -> Self {\n        // if this panics something is very wrong, as the default config\n        // does not build or deserialize into the settings struct\n        Self::builder()\n            .expect(\"Could not build default\")\n            .build()\n            .expect(\"Could not build config\")\n            .try_deserialize()\n            .expect(\"Could not deserialize config\")\n    }\n}\n\n/// Initialize the meta store configuration for testing.\n///\n/// This should only be used in tests. It allows tests to bypass the normal\n/// Settings::new() flow while still being able to use Settings::host_id()\n/// and other meta store dependent functions.\n///\n/// # Safety\n/// This function is not thread-safe with concurrent calls to Settings::new()\n/// or other meta store initialization. Only call from tests.\n#[doc(hidden)]\npub fn init_meta_config_for_testing(meta_db_path: impl Into<String>, local_timeout: f64) {\n    META_CONFIG.set((meta_db_path.into(), local_timeout)).ok();\n}\n\n#[cfg(test)]\npub(crate) fn test_local_timeout() -> f64 {\n    std::env::var(\"ATUIN_TEST_LOCAL_TIMEOUT\")\n        .ok()\n        .and_then(|x| x.parse().ok())\n        // this hardcoded value should be replaced by a simple way to get the\n        // default local_timeout of Settings if possible\n        .unwrap_or(2.0)\n}\n\n#[cfg(test)]\nmod tests {\n    use std::str::FromStr;\n\n    use eyre::Result;\n\n    use super::Timezone;\n\n    #[test]\n    fn can_parse_offset_timezone_spec() -> Result<()> {\n        assert_eq!(Timezone::from_str(\"+02\")?.0.as_hms(), (2, 0, 0));\n        assert_eq!(Timezone::from_str(\"-04\")?.0.as_hms(), (-4, 0, 0));\n        assert_eq!(Timezone::from_str(\"+05:30\")?.0.as_hms(), (5, 30, 0));\n        assert_eq!(Timezone::from_str(\"-09:30\")?.0.as_hms(), (-9, -30, 0));\n\n        // single digit hours are allowed\n        assert_eq!(Timezone::from_str(\"+2\")?.0.as_hms(), (2, 0, 0));\n        assert_eq!(Timezone::from_str(\"-4\")?.0.as_hms(), (-4, 0, 0));\n        assert_eq!(Timezone::from_str(\"+5:30\")?.0.as_hms(), (5, 30, 0));\n        assert_eq!(Timezone::from_str(\"-9:30\")?.0.as_hms(), (-9, -30, 0));\n\n        // fully qualified form\n        assert_eq!(Timezone::from_str(\"+09:30:00\")?.0.as_hms(), (9, 30, 0));\n        assert_eq!(Timezone::from_str(\"-09:30:00\")?.0.as_hms(), (-9, -30, 0));\n\n        // these offsets don't really exist but are supported anyway\n        assert_eq!(Timezone::from_str(\"+0:5\")?.0.as_hms(), (0, 5, 0));\n        assert_eq!(Timezone::from_str(\"-0:5\")?.0.as_hms(), (0, -5, 0));\n        assert_eq!(Timezone::from_str(\"+01:23:45\")?.0.as_hms(), (1, 23, 45));\n        assert_eq!(Timezone::from_str(\"-01:23:45\")?.0.as_hms(), (-1, -23, -45));\n\n        // require a leading sign for clarity\n        assert!(Timezone::from_str(\"5\").is_err());\n        assert!(Timezone::from_str(\"10:30\").is_err());\n\n        Ok(())\n    }\n\n    #[test]\n    fn can_choose_workspace_filters_when_in_git_context() -> Result<()> {\n        let mut settings = super::Settings::default();\n        settings.search.filters = vec![\n            super::FilterMode::Workspace,\n            super::FilterMode::Host,\n            super::FilterMode::Directory,\n            super::FilterMode::Session,\n            super::FilterMode::Global,\n        ];\n        settings.workspaces = true;\n\n        assert_eq!(\n            settings.default_filter_mode(true),\n            super::FilterMode::Workspace,\n        );\n\n        Ok(())\n    }\n\n    #[test]\n    fn wont_choose_workspace_filters_when_not_in_git_context() -> Result<()> {\n        let mut settings = super::Settings::default();\n        settings.search.filters = vec![\n            super::FilterMode::Workspace,\n            super::FilterMode::Host,\n            super::FilterMode::Directory,\n            super::FilterMode::Session,\n            super::FilterMode::Global,\n        ];\n        settings.workspaces = true;\n\n        assert_eq!(settings.default_filter_mode(false), super::FilterMode::Host,);\n\n        Ok(())\n    }\n\n    #[test]\n    fn wont_choose_workspace_filters_when_workspaces_disabled() -> Result<()> {\n        let mut settings = super::Settings::default();\n        settings.search.filters = vec![\n            super::FilterMode::Workspace,\n            super::FilterMode::Host,\n            super::FilterMode::Directory,\n            super::FilterMode::Session,\n            super::FilterMode::Global,\n        ];\n        settings.workspaces = false;\n\n        assert_eq!(settings.default_filter_mode(true), super::FilterMode::Host,);\n\n        Ok(())\n    }\n\n    #[test]\n    fn builder_with_data_dir_uses_custom_paths() -> Result<()> {\n        use std::path::PathBuf;\n\n        let custom_dir = PathBuf::from(\"/custom/data/dir\");\n        let builder = super::Settings::builder_with_data_dir(&custom_dir)?;\n        let config = builder.build()?;\n\n        let db_path: String = config.get(\"db_path\")?;\n        let key_path: String = config.get(\"key_path\")?;\n        let record_store_path: String = config.get(\"record_store_path\")?;\n        let kv_db_path: String = config.get(\"kv.db_path\")?;\n        let scripts_db_path: String = config.get(\"scripts.db_path\")?;\n        let meta_db_path: String = config.get(\"meta.db_path\")?;\n        let daemon_socket_path: String = config.get(\"daemon.socket_path\")?;\n        let daemon_pidfile_path: String = config.get(\"daemon.pidfile_path\")?;\n        let daemon_autostart: bool = config.get(\"daemon.autostart\")?;\n\n        assert_eq!(db_path, custom_dir.join(\"history.db\").to_str().unwrap());\n        assert_eq!(key_path, custom_dir.join(\"key\").to_str().unwrap());\n        assert_eq!(\n            record_store_path,\n            custom_dir.join(\"records.db\").to_str().unwrap()\n        );\n        assert_eq!(kv_db_path, custom_dir.join(\"kv.db\").to_str().unwrap());\n        assert_eq!(\n            scripts_db_path,\n            custom_dir.join(\"scripts.db\").to_str().unwrap()\n        );\n        assert_eq!(meta_db_path, custom_dir.join(\"meta.db\").to_str().unwrap());\n        assert_eq!(\n            daemon_socket_path,\n            atuin_common::utils::runtime_dir()\n                .join(\"atuin.sock\")\n                .to_str()\n                .unwrap()\n        );\n        assert_eq!(\n            daemon_pidfile_path,\n            custom_dir.join(\"atuin-daemon.pid\").to_str().unwrap()\n        );\n        assert!(!daemon_autostart);\n\n        Ok(())\n    }\n\n    #[test]\n    fn effective_data_dir_returns_default_when_not_set() {\n        let effective = super::Settings::effective_data_dir();\n        let default = atuin_common::utils::data_dir();\n\n        assert!(effective.to_str().is_some());\n        assert!(effective.ends_with(\"atuin\") || effective == default);\n    }\n\n    #[test]\n    fn keymap_config_deserializes_simple_binding() {\n        let json = r#\"{\"emacs\": {\"ctrl-c\": \"exit\"}}\"#;\n        let config: super::KeymapConfig = serde_json::from_str(json).unwrap();\n        assert_eq!(config.emacs.len(), 1);\n        match &config.emacs[\"ctrl-c\"] {\n            super::KeyBindingConfig::Simple(s) => assert_eq!(s, \"exit\"),\n            _ => panic!(\"expected Simple variant\"),\n        }\n    }\n\n    #[test]\n    fn keymap_config_deserializes_conditional_binding() {\n        let json = r#\"{\n            \"emacs\": {\n                \"left\": [\n                    {\"when\": \"cursor-at-start\", \"action\": \"exit\"},\n                    {\"action\": \"cursor-left\"}\n                ]\n            }\n        }\"#;\n        let config: super::KeymapConfig = serde_json::from_str(json).unwrap();\n        match &config.emacs[\"left\"] {\n            super::KeyBindingConfig::Rules(rules) => {\n                assert_eq!(rules.len(), 2);\n                assert_eq!(rules[0].when.as_deref(), Some(\"cursor-at-start\"));\n                assert_eq!(rules[0].action, \"exit\");\n                assert!(rules[1].when.is_none());\n                assert_eq!(rules[1].action, \"cursor-left\");\n            }\n            _ => panic!(\"expected Rules variant\"),\n        }\n    }\n\n    #[test]\n    fn keymap_config_deserializes_vim_normal() {\n        let json = r#\"{\"vim-normal\": {\"j\": \"select-next\", \"k\": \"select-previous\"}}\"#;\n        let config: super::KeymapConfig = serde_json::from_str(json).unwrap();\n        assert_eq!(config.vim_normal.len(), 2);\n        assert!(config.emacs.is_empty());\n    }\n\n    #[test]\n    fn keymap_config_is_empty_when_default() {\n        let config = super::KeymapConfig::default();\n        assert!(config.is_empty());\n    }\n\n    #[test]\n    fn keymap_config_mixed_modes() {\n        let json = r#\"{\n            \"emacs\": {\"ctrl-c\": \"exit\"},\n            \"vim-normal\": {\"q\": \"exit\"},\n            \"inspector\": {\"d\": \"delete\"}\n        }\"#;\n        let config: super::KeymapConfig = serde_json::from_str(json).unwrap();\n        assert!(!config.is_empty());\n        assert_eq!(config.emacs.len(), 1);\n        assert_eq!(config.vim_normal.len(), 1);\n        assert_eq!(config.inspector.len(), 1);\n        assert!(config.vim_insert.is_empty());\n        assert!(config.prefix.is_empty());\n    }\n}\n"
  },
  {
    "path": "crates/atuin-client/src/sync.rs",
    "content": "use std::collections::HashSet;\nuse std::iter::FromIterator;\n\nuse eyre::Result;\n\nuse atuin_common::api::AddHistoryRequest;\nuse crypto_secretbox::Key;\nuse time::OffsetDateTime;\n\nuse crate::{\n    api_client,\n    database::Database,\n    encryption::{decrypt, encrypt, load_key},\n    settings::Settings,\n};\n\npub fn hash_str(string: &str) -> String {\n    use sha2::{Digest, Sha256};\n    let mut hasher = Sha256::new();\n    hasher.update(string.as_bytes());\n    hex::encode(hasher.finalize())\n}\n\n// Currently sync is kinda naive, and basically just pages backwards through\n// history. This means newly added stuff shows up properly! We also just use\n// the total count in each database to indicate whether a sync is needed.\n// I think this could be massively improved! If we had a way of easily\n// indicating count per time period (hour, day, week, year, etc) then we can\n// easily pinpoint where we are missing data and what needs downloading. Start\n// with year, then find the week, then the day, then the hour, then download it\n// all! The current naive approach will do for now.\n\n// Check if remote has things we don't, and if so, download them.\n// Returns (num downloaded, total local)\nasync fn sync_download(\n    key: &Key,\n    force: bool,\n    client: &api_client::Client<'_>,\n    db: &impl Database,\n) -> Result<(i64, i64)> {\n    debug!(\"starting sync download\");\n\n    let remote_status = client.status().await?;\n    let remote_count = remote_status.count;\n\n    // useful to ensure we don't even save something that hasn't yet been synced + deleted\n    let remote_deleted =\n        HashSet::<&str>::from_iter(remote_status.deleted.iter().map(String::as_str));\n\n    let initial_local = db.history_count(true).await?;\n    let mut local_count = initial_local;\n\n    let mut last_sync = if force {\n        OffsetDateTime::UNIX_EPOCH\n    } else {\n        Settings::last_sync().await?\n    };\n\n    let mut last_timestamp = OffsetDateTime::UNIX_EPOCH;\n\n    let host = if force { Some(String::from(\"\")) } else { None };\n\n    while remote_count > local_count {\n        let page = client\n            .get_history(last_sync, last_timestamp, host.clone())\n            .await?;\n\n        let history: Vec<_> = page\n            .history\n            .iter()\n            // TODO: handle deletion earlier in this chain\n            .map(|h| serde_json::from_str(h).expect(\"invalid base64\"))\n            .map(|h| decrypt(h, key).expect(\"failed to decrypt history! check your key\"))\n            .map(|mut h| {\n                if remote_deleted.contains(h.id.0.as_str()) {\n                    h.deleted_at = Some(time::OffsetDateTime::now_utc());\n                    h.command = String::from(\"\");\n                }\n\n                h\n            })\n            .collect();\n\n        db.save_bulk(&history).await?;\n\n        local_count = db.history_count(true).await?;\n        let remote_page_size = std::cmp::max(remote_status.page_size, 0) as usize;\n\n        if history.len() < remote_page_size {\n            break;\n        }\n\n        let page_last = history\n            .last()\n            .expect(\"could not get last element of page\")\n            .timestamp;\n\n        // in the case of a small sync frequency, it's possible for history to\n        // be \"lost\" between syncs. In this case we need to rewind the sync\n        // timestamps\n        if page_last == last_timestamp {\n            last_timestamp = OffsetDateTime::UNIX_EPOCH;\n            last_sync -= time::Duration::hours(1);\n        } else {\n            last_timestamp = page_last;\n        }\n    }\n\n    for i in remote_status.deleted {\n        // we will update the stored history to have this data\n        // pretty much everything can be nullified\n        match db.load(i.as_str()).await? {\n            Some(h) => {\n                db.delete(h).await?;\n            }\n            _ => {\n                info!(\n                    \"could not delete history with id {}, not found locally\",\n                    i.as_str()\n                );\n            }\n        }\n    }\n\n    Ok((local_count - initial_local, local_count))\n}\n\n// Check if we have things remote doesn't, and if so, upload them\nasync fn sync_upload(\n    key: &Key,\n    _force: bool,\n    client: &api_client::Client<'_>,\n    db: &impl Database,\n) -> Result<()> {\n    debug!(\"starting sync upload\");\n\n    let remote_status = client.status().await?;\n    let remote_deleted: HashSet<String> = HashSet::from_iter(remote_status.deleted.clone());\n\n    let initial_remote_count = client.count().await?;\n    let mut remote_count = initial_remote_count;\n\n    let local_count = db.history_count(true).await?;\n\n    debug!(\"remote has {remote_count}, we have {local_count}\");\n\n    // first just try the most recent set\n    let mut cursor = OffsetDateTime::now_utc();\n\n    while local_count > remote_count {\n        let last = db.before(cursor, remote_status.page_size).await?;\n        let mut buffer = Vec::new();\n\n        if last.is_empty() {\n            break;\n        }\n\n        for i in last {\n            let data = encrypt(&i, key)?;\n            let data = serde_json::to_string(&data)?;\n\n            let add_hist = AddHistoryRequest {\n                id: i.id.to_string(),\n                timestamp: i.timestamp,\n                data,\n                hostname: hash_str(&i.hostname),\n            };\n\n            buffer.push(add_hist);\n        }\n\n        // anything left over outside of the 100 block size\n        client.post_history(&buffer).await?;\n        cursor = buffer.last().unwrap().timestamp;\n        remote_count = client.count().await?;\n\n        debug!(\"upload cursor: {cursor:?}\");\n    }\n\n    let deleted = db.deleted().await?;\n\n    for i in deleted {\n        if remote_deleted.contains(&i.id.to_string()) {\n            continue;\n        }\n\n        info!(\"deleting {} on remote\", i.id);\n        client.delete_history(i).await?;\n    }\n\n    Ok(())\n}\n\npub async fn sync(settings: &Settings, force: bool, db: &impl Database) -> Result<()> {\n    let client = api_client::Client::new(\n        &settings.sync_address,\n        settings.sync_auth_token().await?,\n        settings.network_connect_timeout,\n        settings.network_timeout,\n    )?;\n\n    Settings::save_sync_time().await?;\n\n    let key = load_key(settings)?; // encryption key\n\n    sync_upload(&key, force, &client, db).await?;\n\n    let download = sync_download(&key, force, &client, db).await?;\n\n    debug!(\"sync downloaded {}\", download.0);\n\n    Ok(())\n}\n"
  },
  {
    "path": "crates/atuin-client/src/theme.rs",
    "content": "use config::{Config, File as ConfigFile, FileFormat};\nuse log;\nuse palette::named;\nuse serde::{Deserialize, Serialize};\nuse serde_json;\nuse std::collections::HashMap;\nuse std::error;\nuse std::io::{Error, ErrorKind};\nuse std::path::PathBuf;\nuse std::sync::LazyLock;\nuse strum_macros;\n\nstatic DEFAULT_MAX_DEPTH: u8 = 10;\n\n// Collection of settable \"meanings\" that can have colors set.\n// NOTE: You can add a new meaning here without breaking backwards compatibility but please:\n//     - update the atuin/docs repository, which has a list of available meanings\n//     - add a fallback in the MEANING_FALLBACKS below, so that themes which do not have it\n//       get a sensible fallback (see Title as an example)\n#[derive(\n    Serialize, Deserialize, Copy, Clone, Hash, Debug, Eq, PartialEq, strum_macros::Display,\n)]\n#[strum(serialize_all = \"camel_case\")]\npub enum Meaning {\n    AlertInfo,\n    AlertWarn,\n    AlertError,\n    Annotation,\n    Base,\n    Guidance,\n    Important,\n    Title,\n    Muted,\n}\n\n#[derive(Clone, Debug, Deserialize, Serialize)]\npub struct ThemeConfig {\n    // Definition of the theme\n    pub theme: ThemeDefinitionConfigBlock,\n\n    // Colors\n    pub colors: HashMap<Meaning, String>,\n}\n\n#[derive(Clone, Debug, Deserialize, Serialize)]\npub struct ThemeDefinitionConfigBlock {\n    /// Name of theme (\"default\" for base)\n    pub name: String,\n\n    /// Whether any theme should be treated as a parent _if available_\n    pub parent: Option<String>,\n}\n\nuse crossterm::style::{Attribute, Attributes, Color, ContentStyle};\n\n// For now, a theme is loaded as a mapping of meanings to colors, but it may be desirable to\n// expand that in the future to general styles, so we populate a Meaning->ContentStyle hashmap.\npub struct Theme {\n    pub name: String,\n    pub parent: Option<String>,\n    pub styles: HashMap<Meaning, ContentStyle>,\n}\n\n// Themes have a number of convenience functions for the most commonly used meanings.\n// The general purpose `as_style` routine gives back a style, but for ease-of-use and to keep\n// theme-related boilerplate minimal, the convenience functions give a color.\nimpl Theme {\n    // This is the base \"default\" color, for general text\n    pub fn get_base(&self) -> ContentStyle {\n        self.styles[&Meaning::Base]\n    }\n\n    pub fn get_info(&self) -> ContentStyle {\n        self.get_alert(log::Level::Info)\n    }\n\n    pub fn get_warning(&self) -> ContentStyle {\n        self.get_alert(log::Level::Warn)\n    }\n\n    pub fn get_error(&self) -> ContentStyle {\n        self.get_alert(log::Level::Error)\n    }\n\n    // The alert meanings may be chosen by the Level enum, rather than the methods above\n    // or the full Meaning enum, to simplify programmatic selection of a log-level.\n    pub fn get_alert(&self, severity: log::Level) -> ContentStyle {\n        self.styles[ALERT_TYPES.get(&severity).unwrap()]\n    }\n\n    pub fn new(\n        name: String,\n        parent: Option<String>,\n        styles: HashMap<Meaning, ContentStyle>,\n    ) -> Theme {\n        Theme {\n            name,\n            parent,\n            styles,\n        }\n    }\n\n    pub fn closest_meaning<'a>(&self, meaning: &'a Meaning) -> &'a Meaning {\n        if self.styles.contains_key(meaning) {\n            meaning\n        } else if MEANING_FALLBACKS.contains_key(meaning) {\n            self.closest_meaning(&MEANING_FALLBACKS[meaning])\n        } else {\n            &Meaning::Base\n        }\n    }\n\n    // General access - if you have a meaning, this will give you a (crossterm) style\n    pub fn as_style(&self, meaning: Meaning) -> ContentStyle {\n        self.styles[self.closest_meaning(&meaning)]\n    }\n\n    // Turns a map of meanings to colornames into a theme\n    // If theme-debug is on, then we will print any colornames that we cannot load,\n    // but we do not have this on in general, as it could print unfiltered text to the terminal\n    // from a theme TOML file. However, it will always return a theme, falling back to\n    // defaults on error, so that a TOML file does not break loading\n    pub fn from_foreground_colors(\n        name: String,\n        parent: Option<&Theme>,\n        foreground_colors: HashMap<Meaning, String>,\n        debug: bool,\n    ) -> Theme {\n        let styles: HashMap<Meaning, ContentStyle> = foreground_colors\n            .iter()\n            .map(|(name, color)| {\n                (\n                    *name,\n                    StyleFactory::from_fg_string(color).unwrap_or_else(|err| {\n                        if debug {\n                            log::warn!(\"Tried to load string as a color unsuccessfully: ({name}={color}) {err}\");\n                        }\n                        ContentStyle::default()\n                    }),\n                )\n            })\n            .collect();\n        Theme::from_map(name, parent, &styles)\n    }\n\n    // Boil down a meaning-color hashmap into a theme, by taking the defaults\n    // for any unknown colors\n    fn from_map(\n        name: String,\n        parent: Option<&Theme>,\n        overrides: &HashMap<Meaning, ContentStyle>,\n    ) -> Theme {\n        let styles = match parent {\n            Some(theme) => Box::new(theme.styles.clone()),\n            None => Box::new(DEFAULT_THEME.styles.clone()),\n        }\n        .iter()\n        .map(|(name, color)| match overrides.get(name) {\n            Some(value) => (*name, *value),\n            None => (*name, *color),\n        })\n        .collect();\n        Theme::new(name, parent.map(|p| p.name.clone()), styles)\n    }\n}\n\n// Use palette to get a color from a string name, if possible\nfn from_string(name: &str) -> Result<Color, String> {\n    if name.is_empty() {\n        return Err(\"Empty string\".into());\n    }\n    let first_char = name.chars().next().unwrap();\n    match first_char {\n        '#' => {\n            let hexcode = &name[1..];\n            let vec: Vec<u8> = hexcode\n                .chars()\n                .collect::<Vec<char>>()\n                .chunks(2)\n                .map(|pair| u8::from_str_radix(pair.iter().collect::<String>().as_str(), 16))\n                .filter_map(|n| n.ok())\n                .collect();\n            if vec.len() != 3 {\n                return Err(\"Could not parse 3 hex values from string\".into());\n            }\n            Ok(Color::Rgb {\n                r: vec[0],\n                g: vec[1],\n                b: vec[2],\n            })\n        }\n        '@' => {\n            // For full flexibility, we need to use serde_json, given\n            // crossterm's approach.\n            serde_json::from_str::<Color>(format!(\"\\\"{}\\\"\", &name[1..]).as_str())\n                .map_err(|_| format!(\"Could not convert color name {name} to Crossterm color\"))\n        }\n        _ => {\n            let srgb = named::from_str(name).ok_or(\"No such color in palette\")?;\n            Ok(Color::Rgb {\n                r: srgb.red,\n                g: srgb.green,\n                b: srgb.blue,\n            })\n        }\n    }\n}\n\npub struct StyleFactory {}\n\nimpl StyleFactory {\n    fn from_fg_string(name: &str) -> Result<ContentStyle, String> {\n        match from_string(name) {\n            Ok(color) => Ok(Self::from_fg_color(color)),\n            Err(err) => Err(err),\n        }\n    }\n\n    // For succinctness, if we are confident that the name will be known,\n    // this routine is available to keep the code readable\n    fn known_fg_string(name: &str) -> ContentStyle {\n        Self::from_fg_string(name).unwrap()\n    }\n\n    fn from_fg_color(color: Color) -> ContentStyle {\n        ContentStyle {\n            foreground_color: Some(color),\n            ..ContentStyle::default()\n        }\n    }\n\n    fn from_fg_color_and_attributes(color: Color, attributes: Attributes) -> ContentStyle {\n        ContentStyle {\n            foreground_color: Some(color),\n            attributes,\n            ..ContentStyle::default()\n        }\n    }\n}\n\n// Built-in themes. Rather than having extra files added before any theming\n// is available, this gives a couple of basic options, demonstrating the use\n// of themes: autumn and marine\nstatic ALERT_TYPES: LazyLock<HashMap<log::Level, Meaning>> = LazyLock::new(|| {\n    HashMap::from([\n        (log::Level::Info, Meaning::AlertInfo),\n        (log::Level::Warn, Meaning::AlertWarn),\n        (log::Level::Error, Meaning::AlertError),\n    ])\n});\n\nstatic MEANING_FALLBACKS: LazyLock<HashMap<Meaning, Meaning>> = LazyLock::new(|| {\n    HashMap::from([\n        (Meaning::Guidance, Meaning::AlertInfo),\n        (Meaning::Annotation, Meaning::AlertInfo),\n        (Meaning::Title, Meaning::Important),\n    ])\n});\n\nstatic DEFAULT_THEME: LazyLock<Theme> = LazyLock::new(|| {\n    Theme::new(\n        \"default\".to_string(),\n        None,\n        HashMap::from([\n            (\n                Meaning::AlertError,\n                StyleFactory::from_fg_color(Color::DarkRed),\n            ),\n            (\n                Meaning::AlertWarn,\n                StyleFactory::from_fg_color(Color::DarkYellow),\n            ),\n            (\n                Meaning::AlertInfo,\n                StyleFactory::from_fg_color(Color::DarkGreen),\n            ),\n            (\n                Meaning::Annotation,\n                StyleFactory::from_fg_color(Color::DarkGrey),\n            ),\n            (\n                Meaning::Guidance,\n                StyleFactory::from_fg_color(Color::DarkBlue),\n            ),\n            (\n                Meaning::Important,\n                StyleFactory::from_fg_color_and_attributes(\n                    Color::White,\n                    Attributes::from(Attribute::Bold),\n                ),\n            ),\n            (Meaning::Muted, StyleFactory::from_fg_color(Color::Grey)),\n            (Meaning::Base, ContentStyle::default()),\n        ]),\n    )\n});\n\nstatic BUILTIN_THEMES: LazyLock<HashMap<&'static str, Theme>> = LazyLock::new(|| {\n    HashMap::from([\n        (\"default\", HashMap::new()),\n        (\n            \"(none)\",\n            HashMap::from([\n                (Meaning::AlertError, ContentStyle::default()),\n                (Meaning::AlertWarn, ContentStyle::default()),\n                (Meaning::AlertInfo, ContentStyle::default()),\n                (Meaning::Annotation, ContentStyle::default()),\n                (Meaning::Guidance, ContentStyle::default()),\n                (Meaning::Important, ContentStyle::default()),\n                (Meaning::Muted, ContentStyle::default()),\n                (Meaning::Base, ContentStyle::default()),\n            ]),\n        ),\n        (\n            \"autumn\",\n            HashMap::from([\n                (\n                    Meaning::AlertError,\n                    StyleFactory::known_fg_string(\"saddlebrown\"),\n                ),\n                (\n                    Meaning::AlertWarn,\n                    StyleFactory::known_fg_string(\"darkorange\"),\n                ),\n                (Meaning::AlertInfo, StyleFactory::known_fg_string(\"gold\")),\n                (\n                    Meaning::Annotation,\n                    StyleFactory::from_fg_color(Color::DarkGrey),\n                ),\n                (Meaning::Guidance, StyleFactory::known_fg_string(\"brown\")),\n            ]),\n        ),\n        (\n            \"marine\",\n            HashMap::from([\n                (\n                    Meaning::AlertError,\n                    StyleFactory::known_fg_string(\"yellowgreen\"),\n                ),\n                (Meaning::AlertWarn, StyleFactory::known_fg_string(\"cyan\")),\n                (\n                    Meaning::AlertInfo,\n                    StyleFactory::known_fg_string(\"turquoise\"),\n                ),\n                (\n                    Meaning::Annotation,\n                    StyleFactory::known_fg_string(\"steelblue\"),\n                ),\n                (\n                    Meaning::Base,\n                    StyleFactory::known_fg_string(\"lightsteelblue\"),\n                ),\n                (Meaning::Guidance, StyleFactory::known_fg_string(\"teal\")),\n            ]),\n        ),\n    ])\n    .iter()\n    .map(|(name, theme)| (*name, Theme::from_map(name.to_string(), None, theme)))\n    .collect()\n});\n\n// To avoid themes being repeatedly loaded, we store them in a theme manager\npub struct ThemeManager {\n    loaded_themes: HashMap<String, Theme>,\n    debug: bool,\n    override_theme_dir: Option<String>,\n}\n\n// Theme-loading logic\nimpl ThemeManager {\n    pub fn new(debug: Option<bool>, theme_dir: Option<String>) -> Self {\n        Self {\n            loaded_themes: HashMap::new(),\n            debug: debug.unwrap_or(false),\n            override_theme_dir: match theme_dir {\n                Some(theme_dir) => Some(theme_dir),\n                None => std::env::var(\"ATUIN_THEME_DIR\").ok(),\n            },\n        }\n    }\n\n    // Try to load a theme from a `{name}.toml` file in the theme directory. If an override is set\n    // for the theme dir (via ATUIN_THEME_DIR env) we should load the theme from there\n    pub fn load_theme_from_file(\n        &mut self,\n        name: &str,\n        max_depth: u8,\n    ) -> Result<&Theme, Box<dyn error::Error>> {\n        let mut theme_file = if let Some(p) = &self.override_theme_dir {\n            if p.is_empty() {\n                return Err(Box::new(Error::new(\n                    ErrorKind::NotFound,\n                    \"Empty theme directory override and could not find theme elsewhere\",\n                )));\n            }\n            PathBuf::from(p)\n        } else {\n            let config_dir = atuin_common::utils::config_dir();\n            let mut theme_file = if let Ok(p) = std::env::var(\"ATUIN_CONFIG_DIR\") {\n                PathBuf::from(p)\n            } else {\n                let mut theme_file = PathBuf::new();\n                theme_file.push(config_dir);\n                theme_file\n            };\n            theme_file.push(\"themes\");\n            theme_file\n        };\n\n        let theme_toml = format![\"{name}.toml\"];\n        theme_file.push(theme_toml);\n\n        let mut config_builder = Config::builder();\n\n        config_builder = config_builder.add_source(ConfigFile::new(\n            theme_file.to_str().unwrap(),\n            FileFormat::Toml,\n        ));\n\n        let config = config_builder.build()?;\n        self.load_theme_from_config(name, config, max_depth)\n    }\n\n    pub fn load_theme_from_config(\n        &mut self,\n        name: &str,\n        config: Config,\n        max_depth: u8,\n    ) -> Result<&Theme, Box<dyn error::Error>> {\n        let debug = self.debug;\n        let theme_config: ThemeConfig = match config.try_deserialize() {\n            Ok(tc) => tc,\n            Err(e) => {\n                return Err(Box::new(Error::new(\n                    ErrorKind::InvalidInput,\n                    format!(\n                        \"Failed to deserialize theme: {}\",\n                        if debug {\n                            e.to_string()\n                        } else {\n                            \"set theme debug on for more info\".to_string()\n                        }\n                    ),\n                )));\n            }\n        };\n        let colors: HashMap<Meaning, String> = theme_config.colors;\n        let parent: Option<&Theme> = match theme_config.theme.parent {\n            Some(parent_name) => {\n                if max_depth == 0 {\n                    return Err(Box::new(Error::new(\n                        ErrorKind::InvalidInput,\n                        \"Parent requested but we hit the recursion limit\",\n                    )));\n                }\n                Some(self.load_theme(parent_name.as_str(), Some(max_depth - 1)))\n            }\n            None => Some(self.load_theme(\"default\", Some(max_depth - 1))),\n        };\n\n        if debug && name != theme_config.theme.name {\n            log::warn!(\n                \"Your theme config name is not the name of your loaded theme {} != {}\",\n                name,\n                theme_config.theme.name\n            );\n        }\n\n        let theme = Theme::from_foreground_colors(theme_config.theme.name, parent, colors, debug);\n        let name = name.to_string();\n        self.loaded_themes.insert(name.clone(), theme);\n        let theme = self.loaded_themes.get(&name).unwrap();\n        Ok(theme)\n    }\n\n    // Check if the requested theme is loaded and, if not, then attempt to get it\n    // from the builtins or, if not there, from file\n    pub fn load_theme(&mut self, name: &str, max_depth: Option<u8>) -> &Theme {\n        if self.loaded_themes.contains_key(name) {\n            return self.loaded_themes.get(name).unwrap();\n        }\n        let built_ins = &BUILTIN_THEMES;\n        match built_ins.get(name) {\n            Some(theme) => theme,\n            None => match self.load_theme_from_file(name, max_depth.unwrap_or(DEFAULT_MAX_DEPTH)) {\n                Ok(theme) => theme,\n                Err(err) => {\n                    log::warn!(\"Could not load theme {name}: {err}\");\n                    built_ins.get(\"(none)\").unwrap()\n                }\n            },\n        }\n    }\n}\n\n#[cfg(test)]\nmod theme_tests {\n    use super::*;\n\n    #[test]\n    fn test_can_load_builtin_theme() {\n        let mut manager = ThemeManager::new(Some(false), Some(\"\".to_string()));\n        let theme = manager.load_theme(\"autumn\", None);\n        assert_eq!(\n            theme.as_style(Meaning::Guidance).foreground_color,\n            from_string(\"brown\").ok()\n        );\n    }\n\n    #[test]\n    fn test_can_create_theme() {\n        let mut manager = ThemeManager::new(Some(false), Some(\"\".to_string()));\n        let mytheme = Theme::new(\n            \"mytheme\".to_string(),\n            None,\n            HashMap::from([(\n                Meaning::AlertError,\n                StyleFactory::known_fg_string(\"yellowgreen\"),\n            )]),\n        );\n        manager.loaded_themes.insert(\"mytheme\".to_string(), mytheme);\n        let theme = manager.load_theme(\"mytheme\", None);\n        assert_eq!(\n            theme.as_style(Meaning::AlertError).foreground_color,\n            from_string(\"yellowgreen\").ok()\n        );\n    }\n\n    #[test]\n    fn test_can_fallback_when_meaning_missing() {\n        let mut manager = ThemeManager::new(Some(false), Some(\"\".to_string()));\n\n        // We use title as an example of a meaning that is not defined\n        // even in the base theme.\n        assert!(!DEFAULT_THEME.styles.contains_key(&Meaning::Title));\n\n        let config = Config::builder()\n            .add_source(ConfigFile::from_str(\n                \"\n        [theme]\n        name = \\\"title_theme\\\"\n\n        [colors]\n        Guidance = \\\"white\\\"\n        AlertInfo = \\\"zomp\\\"\n        \",\n                FileFormat::Toml,\n            ))\n            .build()\n            .unwrap();\n        let theme = manager\n            .load_theme_from_config(\"config_theme\", config, 1)\n            .unwrap();\n\n        // Correctly picks overridden color.\n        assert_eq!(\n            theme.as_style(Meaning::Guidance).foreground_color,\n            from_string(\"white\").ok()\n        );\n\n        // Does not fall back to any color.\n        assert_eq!(theme.as_style(Meaning::AlertInfo).foreground_color, None);\n\n        // Even for the base.\n        assert_eq!(theme.as_style(Meaning::Base).foreground_color, None);\n\n        // Falls back to red as meaning missing from theme, so picks base default.\n        assert_eq!(\n            theme.as_style(Meaning::AlertError).foreground_color,\n            Some(Color::DarkRed)\n        );\n\n        // Falls back to Important as Title not available.\n        assert_eq!(\n            theme.as_style(Meaning::Title).foreground_color,\n            theme.as_style(Meaning::Important).foreground_color,\n        );\n\n        let title_config = Config::builder()\n            .add_source(ConfigFile::from_str(\n                \"\n        [theme]\n        name = \\\"title_theme\\\"\n\n        [colors]\n        Title = \\\"white\\\"\n        AlertInfo = \\\"zomp\\\"\n        \",\n                FileFormat::Toml,\n            ))\n            .build()\n            .unwrap();\n        let title_theme = manager\n            .load_theme_from_config(\"title_theme\", title_config, 1)\n            .unwrap();\n\n        assert_eq!(\n            title_theme.as_style(Meaning::Title).foreground_color,\n            Some(Color::White)\n        );\n    }\n\n    #[test]\n    fn test_no_fallbacks_are_circular() {\n        let mytheme = Theme::new(\"mytheme\".to_string(), None, HashMap::from([]));\n        MEANING_FALLBACKS\n            .iter()\n            .for_each(|pair| assert_eq!(mytheme.closest_meaning(pair.0), &Meaning::Base))\n    }\n\n    #[test]\n    fn test_can_get_colors_via_convenience_functions() {\n        let mut manager = ThemeManager::new(Some(true), Some(\"\".to_string()));\n        let theme = manager.load_theme(\"default\", None);\n        assert_eq!(theme.get_error().foreground_color.unwrap(), Color::DarkRed);\n        assert_eq!(\n            theme.get_warning().foreground_color.unwrap(),\n            Color::DarkYellow\n        );\n        assert_eq!(theme.get_info().foreground_color.unwrap(), Color::DarkGreen);\n        assert_eq!(theme.get_base().foreground_color, None);\n        assert_eq!(\n            theme.get_alert(log::Level::Error).foreground_color.unwrap(),\n            Color::DarkRed\n        )\n    }\n\n    #[test]\n    fn test_can_use_parent_theme_for_fallbacks() {\n        testing_logger::setup();\n\n        let mut manager = ThemeManager::new(Some(false), Some(\"\".to_string()));\n\n        // First, we introduce a base theme\n        let solarized = Config::builder()\n            .add_source(ConfigFile::from_str(\n                \"\n        [theme]\n        name = \\\"solarized\\\"\n\n        [colors]\n        Guidance = \\\"white\\\"\n        AlertInfo = \\\"pink\\\"\n        \",\n                FileFormat::Toml,\n            ))\n            .build()\n            .unwrap();\n        let solarized_theme = manager\n            .load_theme_from_config(\"solarized\", solarized, 1)\n            .unwrap();\n\n        assert_eq!(\n            solarized_theme\n                .as_style(Meaning::AlertInfo)\n                .foreground_color,\n            from_string(\"pink\").ok()\n        );\n\n        // Then we introduce a derived theme\n        let unsolarized = Config::builder()\n            .add_source(ConfigFile::from_str(\n                \"\n        [theme]\n        name = \\\"unsolarized\\\"\n        parent = \\\"solarized\\\"\n\n        [colors]\n        AlertInfo = \\\"red\\\"\n        \",\n                FileFormat::Toml,\n            ))\n            .build()\n            .unwrap();\n        let unsolarized_theme = manager\n            .load_theme_from_config(\"unsolarized\", unsolarized, 1)\n            .unwrap();\n\n        // It will take its own values\n        assert_eq!(\n            unsolarized_theme\n                .as_style(Meaning::AlertInfo)\n                .foreground_color,\n            from_string(\"red\").ok()\n        );\n\n        // ...or fall back to the parent\n        assert_eq!(\n            unsolarized_theme\n                .as_style(Meaning::Guidance)\n                .foreground_color,\n            from_string(\"white\").ok()\n        );\n\n        testing_logger::validate(|captured_logs| assert_eq!(captured_logs.len(), 0));\n\n        // If the parent is not found, we end up with the no theme colors or styling\n        // as this is considered a (soft) error state.\n        let nunsolarized = Config::builder()\n            .add_source(ConfigFile::from_str(\n                \"\n        [theme]\n        name = \\\"nunsolarized\\\"\n        parent = \\\"nonsolarized\\\"\n\n        [colors]\n        AlertInfo = \\\"red\\\"\n        \",\n                FileFormat::Toml,\n            ))\n            .build()\n            .unwrap();\n        let nunsolarized_theme = manager\n            .load_theme_from_config(\"nunsolarized\", nunsolarized, 1)\n            .unwrap();\n\n        assert_eq!(\n            nunsolarized_theme\n                .as_style(Meaning::Guidance)\n                .foreground_color,\n            None\n        );\n\n        testing_logger::validate(|captured_logs| {\n            assert_eq!(captured_logs.len(), 1);\n            assert_eq!(\n                captured_logs[0].body,\n                \"Could not load theme nonsolarized: Empty theme directory override and could not find theme elsewhere\"\n            );\n            assert_eq!(captured_logs[0].level, log::Level::Warn)\n        });\n    }\n\n    #[test]\n    fn test_can_debug_theme() {\n        testing_logger::setup();\n        [true, false].iter().for_each(|debug| {\n            let mut manager = ThemeManager::new(Some(*debug), Some(\"\".to_string()));\n            let config = Config::builder()\n                .add_source(ConfigFile::from_str(\n                    \"\n            [theme]\n            name = \\\"mytheme\\\"\n\n            [colors]\n            Guidance = \\\"white\\\"\n            AlertInfo = \\\"xinetic\\\"\n            \",\n                    FileFormat::Toml,\n                ))\n                .build()\n                .unwrap();\n            manager\n                .load_theme_from_config(\"config_theme\", config, 1)\n                .unwrap();\n            testing_logger::validate(|captured_logs| {\n                if *debug {\n                    assert_eq!(captured_logs.len(), 2);\n                    assert_eq!(\n                        captured_logs[0].body,\n                        \"Your theme config name is not the name of your loaded theme config_theme != mytheme\"\n                    );\n                    assert_eq!(captured_logs[0].level, log::Level::Warn);\n                    assert_eq!(\n                        captured_logs[1].body,\n                        \"Tried to load string as a color unsuccessfully: (AlertInfo=xinetic) No such color in palette\"\n                    );\n                    assert_eq!(captured_logs[1].level, log::Level::Warn)\n                } else {\n                    assert_eq!(captured_logs.len(), 0)\n                }\n            })\n        })\n    }\n\n    #[test]\n    fn test_can_parse_color_strings_correctly() {\n        assert_eq!(\n            from_string(\"brown\").unwrap(),\n            Color::Rgb {\n                r: 165,\n                g: 42,\n                b: 42\n            }\n        );\n\n        assert_eq!(from_string(\"\"), Err(\"Empty string\".into()));\n\n        [\"manatee\", \"caput mortuum\", \"123456\"]\n            .iter()\n            .for_each(|inp| {\n                assert_eq!(from_string(inp), Err(\"No such color in palette\".into()));\n            });\n\n        assert_eq!(\n            from_string(\"#ff1122\").unwrap(),\n            Color::Rgb {\n                r: 255,\n                g: 17,\n                b: 34\n            }\n        );\n        [\"#1122\", \"#ffaa112\", \"#brown\"].iter().for_each(|inp| {\n            assert_eq!(\n                from_string(inp),\n                Err(\"Could not parse 3 hex values from string\".into())\n            );\n        });\n\n        assert_eq!(from_string(\"@dark_grey\").unwrap(), Color::DarkGrey);\n        assert_eq!(\n            from_string(\"@rgb_(255,255,255)\").unwrap(),\n            Color::Rgb {\n                r: 255,\n                g: 255,\n                b: 255\n            }\n        );\n        assert_eq!(from_string(\"@ansi_(255)\").unwrap(), Color::AnsiValue(255));\n        [\"@\", \"@DarkGray\", \"@Dark 4ay\", \"@ansi(256)\"]\n            .iter()\n            .for_each(|inp| {\n                assert_eq!(\n                    from_string(inp),\n                    Err(format!(\n                        \"Could not convert color name {inp} to Crossterm color\"\n                    ))\n                );\n            });\n    }\n}\n"
  },
  {
    "path": "crates/atuin-client/src/utils.rs",
    "content": "pub(crate) fn get_hostname() -> String {\n    std::env::var(\"ATUIN_HOST_NAME\")\n        .unwrap_or_else(|_| whoami::hostname().unwrap_or_else(|_| \"unknown-host\".to_string()))\n}\n\npub(crate) fn get_username() -> String {\n    std::env::var(\"ATUIN_HOST_USER\")\n        .unwrap_or_else(|_| whoami::username().unwrap_or_else(|_| \"unknown-user\".to_string()))\n}\n\n/// Returns a pair of the hostname and username, separated by a colon.\npub(crate) fn get_host_user() -> String {\n    format!(\"{}:{}\", get_hostname(), get_username())\n}\n"
  },
  {
    "path": "crates/atuin-client/tests/data/xonsh/xonsh-82eafbf5-9f43-489a-80d2-61c7dc6ef542.json",
    "content": "{\"locs\": [        69,       3371,       3451,       3978],\n \"index\": {\"offsets\":{\"__total__\":0,\"cmds\":[{\"__total__\":10,\"cwd\":18,\"inp\":78,\"rtn\":96,\"ts\":[106,125,105]},{\"__total__\":149,\"cwd\":157,\"inp\":217,\"rtn\":234,\"ts\":[244,263,243]},9],\"env\":{\"ATUIN_SESSION\":314,\"BASH_COMPLETIONS\":370,\"COLORTERM\":433,\"DBUS_SESSION_BUS_ADDRESS\":474,\"DESKTOP_SESSION\":529,\"DISPLAY\":550,\"GDMSESSION\":570,\"GIO_LAUNCHED_DESKTOP_FILE\":609,\"GIO_LAUNCHED_DESKTOP_FILE_PID\":704,\"GJS_DEBUG_OUTPUT\":734,\"GJS_DEBUG_TOPICS\":764,\"GNOME_DESKTOP_SESSION_ID\":811,\"GNOME_SETUP_DISPLAY\":856,\"GNOME_SHELL_SESSION_MODE\":890,\"GTK_MODULES\":915,\"HOME\":942,\"IM_CONFIG_PHASE\":976,\"INVOCATION_ID\":998,\"JOURNAL_STREAM\":1052,\"LANG\":1071,\"LOGNAME\":1097,\"MANAGERPID\":1118,\"MOZ_ENABLE_WAYLAND\":1148,\"PATH\":1161,\"PWD\":1736,\"PYENV_DIR\":1802,\"PYENV_HOOK_PATH\":1874,\"PYENV_ROOT\":2048,\"PYENV_SHELL\":2086,\"PYENV_VERSION\":2111,\"QT_ACCESSIBILITY\":2141,\"QT_IM_MODULE\":2162,\"SESSION_MANAGER\":2189,\"SHELL\":2279,\"SHLVL\":2303,\"SSH_AGENT_LAUNCHER\":2330,\"SSH_AUTH_SOCK\":2364,\"SSL_CERT_DIR\":2415,\"SSL_CERT_FILE\":2458,\"SYSTEMD_EXEC_PID\":2525,\"TERM\":2541,\"TERM_PROGRAM\":2575,\"TERM_PROGRAM_VERSION\":2610,\"THREAD_SUBPROCS\":2657,\"USER\":2670,\"USERNAME\":2689,\"WAYLAND_DISPLAY\":2715,\"WEZTERM_CONFIG_DIR\":2750,\"WEZTERM_CONFIG_FILE\":2806,\"WEZTERM_EXECUTABLE\":2874,\"WEZTERM_EXECUTABLE_DIR\":2927,\"WEZTERM_PANE\":2957,\"WEZTERM_UNIX_SOCKET\":2986,\"XAUTHORITY\":3047,\"XDG_CONFIG_DIRS\":3116,\"XDG_CURRENT_DESKTOP\":3176,\"XDG_DATA_DIRS\":3209,\"XDG_MENU_PREFIX\":3316,\"XDG_RUNTIME_DIR\":3345,\"XDG_SESSION_CLASS\":3387,\"XDG_SESSION_DESKTOP\":3418,\"XDG_SESSION_TYPE\":3448,\"XMODIFIERS\":3473,\"XONSHRC\":3496,\"XONSHRC_DIR\":3594,\"XONSH_CAPTURE_ALWAYS\":3674,\"XONSH_CONFIG_DIR\":3698,\"XONSH_DATA_DIR\":3747,\"XONSH_INTERACTIVE\":3805,\"XONSH_LOGIN\":3825,\"XONSH_VERSION\":3847,\"__total__\":296},\"locked\":3869,\"sessionid\":3889,\"ts\":[3936,3956,3935]},\"sizes\":{\"__total__\":3978,\"cmds\":[{\"__total__\":137,\"cwd\":51,\"inp\":9,\"rtn\":1,\"ts\":[17,18,40]},{\"__total__\":136,\"cwd\":51,\"inp\":8,\"rtn\":1,\"ts\":[17,18,40]},278],\"env\":{\"ATUIN_SESSION\":34,\"BASH_COMPLETIONS\":48,\"COLORTERM\":11,\"DBUS_SESSION_BUS_ADDRESS\":34,\"DESKTOP_SESSION\":8,\"DISPLAY\":4,\"GDMSESSION\":8,\"GIO_LAUNCHED_DESKTOP_FILE\":60,\"GIO_LAUNCHED_DESKTOP_FILE_PID\":8,\"GJS_DEBUG_OUTPUT\":8,\"GJS_DEBUG_TOPICS\":17,\"GNOME_DESKTOP_SESSION_ID\":20,\"GNOME_SETUP_DISPLAY\":4,\"GNOME_SHELL_SESSION_MODE\":8,\"GTK_MODULES\":17,\"HOME\":13,\"IM_CONFIG_PHASE\":3,\"INVOCATION_ID\":34,\"JOURNAL_STREAM\":9,\"LANG\":13,\"LOGNAME\":5,\"MANAGERPID\":6,\"MOZ_ENABLE_WAYLAND\":3,\"PATH\":566,\"PWD\":51,\"PYENV_DIR\":51,\"PYENV_HOOK_PATH\":158,\"PYENV_ROOT\":21,\"PYENV_SHELL\":6,\"PYENV_VERSION\":8,\"QT_ACCESSIBILITY\":3,\"QT_IM_MODULE\":6,\"SESSION_MANAGER\":79,\"SHELL\":13,\"SHLVL\":3,\"SSH_AGENT_LAUNCHER\":15,\"SSH_AUTH_SOCK\":33,\"SSL_CERT_DIR\":24,\"SSL_CERT_FILE\":45,\"SYSTEMD_EXEC_PID\":6,\"TERM\":16,\"TERM_PROGRAM\":9,\"TERM_PROGRAM_VERSION\":26,\"THREAD_SUBPROCS\":3,\"USER\":5,\"USERNAME\":5,\"WAYLAND_DISPLAY\":11,\"WEZTERM_CONFIG_DIR\":31,\"WEZTERM_CONFIG_FILE\":44,\"WEZTERM_EXECUTABLE\":25,\"WEZTERM_EXECUTABLE_DIR\":12,\"WEZTERM_PANE\":4,\"WEZTERM_UNIX_SOCKET\":45,\"XAUTHORITY\":48,\"XDG_CONFIG_DIRS\":35,\"XDG_CURRENT_DESKTOP\":14,\"XDG_DATA_DIRS\":86,\"XDG_MENU_PREFIX\":8,\"XDG_RUNTIME_DIR\":19,\"XDG_SESSION_CLASS\":6,\"XDG_SESSION_DESKTOP\":8,\"XDG_SESSION_TYPE\":9,\"XMODIFIERS\":10,\"XONSHRC\":81,\"XONSHRC_DIR\":54,\"XONSH_CAPTURE_ALWAYS\":2,\"XONSH_CONFIG_DIR\":29,\"XONSH_DATA_DIR\":35,\"XONSH_INTERACTIVE\":3,\"XONSH_LOGIN\":3,\"XONSH_VERSION\":8,\"__total__\":3561},\"locked\":5,\"sessionid\":38,\"ts\":[18,18,41]}},\n \"data\": {\"cmds\": [{\"cwd\": \"\\/home\\/user\\/Documents\\/code\\/atuin\\/atuin-client\", \"inp\": \"false\\n\", \"rtn\": 1, \"ts\": [1707241291.142516, 1707241291.1527853]\n}\n, {\"cwd\": \"\\/home\\/user\\/Documents\\/code\\/atuin\\/atuin-client\", \"inp\": \"exit\\n\", \"rtn\": 0, \"ts\": [1707241292.271584, 1707241292.2758434]\n}\n]\n, \"env\": {\"ATUIN_SESSION\": \"018d7f82ad167dc4888ca0bf294d2bfd\", \"BASH_COMPLETIONS\": \"\\/usr\\/share\\/bash-completion\\/bash_completion\", \"COLORTERM\": \"truecolor\", \"DBUS_SESSION_BUS_ADDRESS\": \"unix:path=\\/run\\/user\\/1000\\/bus\", \"DESKTOP_SESSION\": \"ubuntu\", \"DISPLAY\": \":0\", \"GDMSESSION\": \"ubuntu\", \"GIO_LAUNCHED_DESKTOP_FILE\": \"\\/usr\\/share\\/applications\\/org.wezfurlong.wezterm.desktop\", \"GIO_LAUNCHED_DESKTOP_FILE_PID\": \"196859\", \"GJS_DEBUG_OUTPUT\": \"stderr\", \"GJS_DEBUG_TOPICS\": \"JS ERROR;JS LOG\", \"GNOME_DESKTOP_SESSION_ID\": \"this-is-deprecated\", \"GNOME_SETUP_DISPLAY\": \":1\", \"GNOME_SHELL_SESSION_MODE\": \"ubuntu\", \"GTK_MODULES\": \"gail:atk-bridge\", \"HOME\": \"\\/home\\/user\", \"IM_CONFIG_PHASE\": \"1\", \"INVOCATION_ID\": \"4f121e7ad56c41a6b84aa3cbe1ad61fa\", \"JOURNAL_STREAM\": \"8:37187\", \"LANG\": \"en_US.UTF-8\", \"LOGNAME\": \"user\", \"MANAGERPID\": \"2118\", \"MOZ_ENABLE_WAYLAND\": \"1\", \"PATH\": \"\\/home\\/user\\/.pyenv\\/versions\\/3.12.0\\/bin:\\/home\\/user\\/.pyenv\\/libexec:\\/home\\/user\\/.pyenv\\/plugins\\/python-build\\/bin:\\/home\\/user\\/.pyenv\\/plugins\\/pyenv-virtualenv\\/bin:\\/home\\/user\\/.pyenv\\/plugins\\/pyenv-update\\/bin:\\/home\\/user\\/.pyenv\\/plugins\\/pyenv-doctor\\/bin:\\/home\\/user\\/.cargo\\/bin:\\/home\\/user\\/.pyenv\\/shims:\\/home\\/user\\/.pyenv\\/bin:\\/home\\/user\\/bin:\\/home\\/user\\/bin:\\/usr\\/local\\/sbin:\\/usr\\/local\\/bin:\\/usr\\/sbin:\\/usr\\/bin:\\/sbin:\\/bin:\\/usr\\/games:\\/usr\\/local\\/games:\\/snap\\/bin:\\/snap\\/bin:\\/home\\/user\\/.local\\/share\\/JetBrains\\/Toolbox\\/scripts\", \"PWD\": \"\\/home\\/user\\/Documents\\/code\\/atuin\\/atuin-client\", \"PYENV_DIR\": \"\\/home\\/user\\/Documents\\/code\\/atuin\\/atuin-client\", \"PYENV_HOOK_PATH\": \"\\/home\\/user\\/.pyenv\\/pyenv.d:\\/usr\\/local\\/etc\\/pyenv.d:\\/etc\\/pyenv.d:\\/usr\\/lib\\/pyenv\\/hooks:\\/home\\/user\\/.pyenv\\/plugins\\/pyenv-virtualenv\\/etc\\/pyenv.d\", \"PYENV_ROOT\": \"\\/home\\/user\\/.pyenv\", \"PYENV_SHELL\": \"bash\", \"PYENV_VERSION\": \"3.12.0\", \"QT_ACCESSIBILITY\": \"1\", \"QT_IM_MODULE\": \"ibus\", \"SESSION_MANAGER\": \"local\\/box:@\\/tmp\\/.ICE-unix\\/2452,unix\\/box:\\/tmp\\/.ICE-unix\\/2452\", \"SHELL\": \"\\/bin\\/bash\", \"SHLVL\": \"1\", \"SSH_AGENT_LAUNCHER\": \"gnome-keyring\", \"SSH_AUTH_SOCK\": \"\\/run\\/user\\/1000\\/keyring\\/ssh\", \"SSL_CERT_DIR\": \"\\/usr\\/lib\\/ssl\\/certs\", \"SSL_CERT_FILE\": \"\\/usr\\/lib\\/ssl\\/certs\\/ca-certificates.crt\", \"SYSTEMD_EXEC_PID\": \"2470\", \"TERM\": \"xterm-256color\", \"TERM_PROGRAM\": \"WezTerm\", \"TERM_PROGRAM_VERSION\": \"20240127-113634-bbcac864\", \"THREAD_SUBPROCS\": \"1\", \"USER\": \"user\", \"USERNAME\": \"user\", \"WAYLAND_DISPLAY\": \"wayland-0\", \"WEZTERM_CONFIG_DIR\": \"\\/home\\/user\\/.config\\/wezterm\", \"WEZTERM_CONFIG_FILE\": \"\\/home\\/user\\/.config\\/wezterm\\/wezterm.lua\", \"WEZTERM_EXECUTABLE\": \"\\/usr\\/bin\\/wezterm-gui\", \"WEZTERM_EXECUTABLE_DIR\": \"\\/usr\\/bin\", \"WEZTERM_PANE\": \"41\", \"WEZTERM_UNIX_SOCKET\": \"\\/run\\/user\\/1000\\/wezterm\\/gui-sock-196859\", \"XAUTHORITY\": \"\\/run\\/user\\/1000\\/.mutter-Xwaylandauth.T986H2\", \"XDG_CONFIG_DIRS\": \"\\/etc\\/xdg\\/xdg-ubuntu:\\/etc\\/xdg\", \"XDG_CURRENT_DESKTOP\": \"ubuntu:GNOME\", \"XDG_DATA_DIRS\": \"\\/usr\\/share\\/ubuntu:\\/usr\\/local\\/share\\/:\\/usr\\/share\\/:\\/var\\/lib\\/snapd\\/desktop\", \"XDG_MENU_PREFIX\": \"gnome-\", \"XDG_RUNTIME_DIR\": \"\\/run\\/user\\/1000\", \"XDG_SESSION_CLASS\": \"user\", \"XDG_SESSION_DESKTOP\": \"ubuntu\", \"XDG_SESSION_TYPE\": \"wayland\", \"XMODIFIERS\": \"@im=ibus\", \"XONSHRC\": \"\\/etc\\/xonsh\\/xonshrc:\\/home\\/user\\/.config\\/xonsh\\/rc.xsh:\\/home\\/user\\/.xonshrc\", \"XONSHRC_DIR\": \"\\/etc\\/xonsh\\/rc.d:\\/home\\/user\\/.config\\/xonsh\\/rc.d\", \"XONSH_CAPTURE_ALWAYS\": \"\", \"XONSH_CONFIG_DIR\": \"\\/home\\/user\\/.config\\/xonsh\", \"XONSH_DATA_DIR\": \"\\/home\\/user\\/.local\\/share\\/xonsh\", \"XONSH_INTERACTIVE\": \"1\", \"XONSH_LOGIN\": \"1\", \"XONSH_VERSION\": \"0.14.2\"}\n, \"locked\": false, \"sessionid\": \"82eafbf5-9f43-489a-80d2-61c7dc6ef542\", \"ts\": [1707241286.9361255, 1707241292.3081477]\n}\n\n}\n"
  },
  {
    "path": "crates/atuin-client/tests/data/xonsh/xonsh-de16af90-9148-4461-8df3-5b5659c6420d.json",
    "content": "{\"locs\": [        69,       3372,       3452,       3936],\n \"index\": {\"offsets\":{\"__total__\":0,\"cmds\":[{\"__total__\":10,\"cwd\":18,\"inp\":64,\"rtn\":94,\"ts\":[104,124,103]},{\"__total__\":148,\"cwd\":156,\"inp\":202,\"rtn\":220,\"ts\":[230,250,229]},9],\"env\":{\"ATUIN_SESSION\":300,\"BASH_COMPLETIONS\":356,\"COLORTERM\":419,\"DBUS_SESSION_BUS_ADDRESS\":460,\"DESKTOP_SESSION\":515,\"DISPLAY\":536,\"GDMSESSION\":556,\"GIO_LAUNCHED_DESKTOP_FILE\":595,\"GIO_LAUNCHED_DESKTOP_FILE_PID\":690,\"GJS_DEBUG_OUTPUT\":720,\"GJS_DEBUG_TOPICS\":750,\"GNOME_DESKTOP_SESSION_ID\":797,\"GNOME_SETUP_DISPLAY\":842,\"GNOME_SHELL_SESSION_MODE\":876,\"GTK_MODULES\":901,\"HOME\":928,\"IM_CONFIG_PHASE\":962,\"INVOCATION_ID\":984,\"JOURNAL_STREAM\":1038,\"LANG\":1057,\"LOGNAME\":1083,\"MANAGERPID\":1104,\"MOZ_ENABLE_WAYLAND\":1134,\"PATH\":1147,\"PWD\":1722,\"PYENV_DIR\":1774,\"PYENV_HOOK_PATH\":1832,\"PYENV_ROOT\":2006,\"PYENV_SHELL\":2044,\"PYENV_VERSION\":2069,\"QT_ACCESSIBILITY\":2099,\"QT_IM_MODULE\":2120,\"SESSION_MANAGER\":2147,\"SHELL\":2237,\"SHLVL\":2261,\"SSH_AGENT_LAUNCHER\":2288,\"SSH_AUTH_SOCK\":2322,\"SSL_CERT_DIR\":2373,\"SSL_CERT_FILE\":2416,\"SYSTEMD_EXEC_PID\":2483,\"TERM\":2499,\"TERM_PROGRAM\":2533,\"TERM_PROGRAM_VERSION\":2568,\"THREAD_SUBPROCS\":2615,\"USER\":2628,\"USERNAME\":2647,\"WAYLAND_DISPLAY\":2673,\"WEZTERM_CONFIG_DIR\":2708,\"WEZTERM_CONFIG_FILE\":2764,\"WEZTERM_EXECUTABLE\":2832,\"WEZTERM_EXECUTABLE_DIR\":2885,\"WEZTERM_PANE\":2915,\"WEZTERM_UNIX_SOCKET\":2944,\"XAUTHORITY\":3005,\"XDG_CONFIG_DIRS\":3074,\"XDG_CURRENT_DESKTOP\":3134,\"XDG_DATA_DIRS\":3167,\"XDG_MENU_PREFIX\":3274,\"XDG_RUNTIME_DIR\":3303,\"XDG_SESSION_CLASS\":3345,\"XDG_SESSION_DESKTOP\":3376,\"XDG_SESSION_TYPE\":3406,\"XMODIFIERS\":3431,\"XONSHRC\":3454,\"XONSHRC_DIR\":3552,\"XONSH_CAPTURE_ALWAYS\":3632,\"XONSH_CONFIG_DIR\":3656,\"XONSH_DATA_DIR\":3705,\"XONSH_INTERACTIVE\":3763,\"XONSH_LOGIN\":3783,\"XONSH_VERSION\":3805,\"__total__\":282},\"locked\":3827,\"sessionid\":3847,\"ts\":[3894,3914,3893]},\"sizes\":{\"__total__\":3936,\"cmds\":[{\"__total__\":136,\"cwd\":37,\"inp\":21,\"rtn\":1,\"ts\":[18,18,41]},{\"__total__\":123,\"cwd\":37,\"inp\":9,\"rtn\":1,\"ts\":[18,17,40]},264],\"env\":{\"ATUIN_SESSION\":34,\"BASH_COMPLETIONS\":48,\"COLORTERM\":11,\"DBUS_SESSION_BUS_ADDRESS\":34,\"DESKTOP_SESSION\":8,\"DISPLAY\":4,\"GDMSESSION\":8,\"GIO_LAUNCHED_DESKTOP_FILE\":60,\"GIO_LAUNCHED_DESKTOP_FILE_PID\":8,\"GJS_DEBUG_OUTPUT\":8,\"GJS_DEBUG_TOPICS\":17,\"GNOME_DESKTOP_SESSION_ID\":20,\"GNOME_SETUP_DISPLAY\":4,\"GNOME_SHELL_SESSION_MODE\":8,\"GTK_MODULES\":17,\"HOME\":13,\"IM_CONFIG_PHASE\":3,\"INVOCATION_ID\":34,\"JOURNAL_STREAM\":9,\"LANG\":13,\"LOGNAME\":5,\"MANAGERPID\":6,\"MOZ_ENABLE_WAYLAND\":3,\"PATH\":566,\"PWD\":37,\"PYENV_DIR\":37,\"PYENV_HOOK_PATH\":158,\"PYENV_ROOT\":21,\"PYENV_SHELL\":6,\"PYENV_VERSION\":8,\"QT_ACCESSIBILITY\":3,\"QT_IM_MODULE\":6,\"SESSION_MANAGER\":79,\"SHELL\":13,\"SHLVL\":3,\"SSH_AGENT_LAUNCHER\":15,\"SSH_AUTH_SOCK\":33,\"SSL_CERT_DIR\":24,\"SSL_CERT_FILE\":45,\"SYSTEMD_EXEC_PID\":6,\"TERM\":16,\"TERM_PROGRAM\":9,\"TERM_PROGRAM_VERSION\":26,\"THREAD_SUBPROCS\":3,\"USER\":5,\"USERNAME\":5,\"WAYLAND_DISPLAY\":11,\"WEZTERM_CONFIG_DIR\":31,\"WEZTERM_CONFIG_FILE\":44,\"WEZTERM_EXECUTABLE\":25,\"WEZTERM_EXECUTABLE_DIR\":12,\"WEZTERM_PANE\":4,\"WEZTERM_UNIX_SOCKET\":45,\"XAUTHORITY\":48,\"XDG_CONFIG_DIRS\":35,\"XDG_CURRENT_DESKTOP\":14,\"XDG_DATA_DIRS\":86,\"XDG_MENU_PREFIX\":8,\"XDG_RUNTIME_DIR\":19,\"XDG_SESSION_CLASS\":6,\"XDG_SESSION_DESKTOP\":8,\"XDG_SESSION_TYPE\":9,\"XMODIFIERS\":10,\"XONSHRC\":81,\"XONSHRC_DIR\":54,\"XONSH_CAPTURE_ALWAYS\":2,\"XONSH_CONFIG_DIR\":29,\"XONSH_DATA_DIR\":35,\"XONSH_INTERACTIVE\":3,\"XONSH_LOGIN\":3,\"XONSH_VERSION\":8,\"__total__\":3533},\"locked\":5,\"sessionid\":38,\"ts\":[18,18,41]}},\n \"data\": {\"cmds\": [{\"cwd\": \"\\/home\\/user\\/Documents\\/code\\/atuin\", \"inp\": \"echo hello world!\\n\", \"rtn\": 0, \"ts\": [1707193079.4782722, 1707193079.4829233]\n}\n, {\"cwd\": \"\\/home\\/user\\/Documents\\/code\\/atuin\", \"inp\": \"ls -l\\n\", \"rtn\": 0, \"ts\": [1707193081.7063284, 1707193081.727617]\n}\n]\n, \"env\": {\"ATUIN_SESSION\": \"018d7ca2e953742e9826012f30115040\", \"BASH_COMPLETIONS\": \"\\/usr\\/share\\/bash-completion\\/bash_completion\", \"COLORTERM\": \"truecolor\", \"DBUS_SESSION_BUS_ADDRESS\": \"unix:path=\\/run\\/user\\/1000\\/bus\", \"DESKTOP_SESSION\": \"ubuntu\", \"DISPLAY\": \":0\", \"GDMSESSION\": \"ubuntu\", \"GIO_LAUNCHED_DESKTOP_FILE\": \"\\/usr\\/share\\/applications\\/org.wezfurlong.wezterm.desktop\", \"GIO_LAUNCHED_DESKTOP_FILE_PID\": \"196859\", \"GJS_DEBUG_OUTPUT\": \"stderr\", \"GJS_DEBUG_TOPICS\": \"JS ERROR;JS LOG\", \"GNOME_DESKTOP_SESSION_ID\": \"this-is-deprecated\", \"GNOME_SETUP_DISPLAY\": \":1\", \"GNOME_SHELL_SESSION_MODE\": \"ubuntu\", \"GTK_MODULES\": \"gail:atk-bridge\", \"HOME\": \"\\/home\\/user\", \"IM_CONFIG_PHASE\": \"1\", \"INVOCATION_ID\": \"4f121e7ad56c41a6b84aa3cbe1ad61fa\", \"JOURNAL_STREAM\": \"8:37187\", \"LANG\": \"en_US.UTF-8\", \"LOGNAME\": \"user\", \"MANAGERPID\": \"2118\", \"MOZ_ENABLE_WAYLAND\": \"1\", \"PATH\": \"\\/home\\/user\\/.pyenv\\/versions\\/3.12.0\\/bin:\\/home\\/user\\/.pyenv\\/libexec:\\/home\\/user\\/.pyenv\\/plugins\\/python-build\\/bin:\\/home\\/user\\/.pyenv\\/plugins\\/pyenv-virtualenv\\/bin:\\/home\\/user\\/.pyenv\\/plugins\\/pyenv-update\\/bin:\\/home\\/user\\/.pyenv\\/plugins\\/pyenv-doctor\\/bin:\\/home\\/user\\/.cargo\\/bin:\\/home\\/user\\/.pyenv\\/shims:\\/home\\/user\\/.pyenv\\/bin:\\/home\\/user\\/bin:\\/home\\/user\\/bin:\\/usr\\/local\\/sbin:\\/usr\\/local\\/bin:\\/usr\\/sbin:\\/usr\\/bin:\\/sbin:\\/bin:\\/usr\\/games:\\/usr\\/local\\/games:\\/snap\\/bin:\\/snap\\/bin:\\/home\\/user\\/.local\\/share\\/JetBrains\\/Toolbox\\/scripts\", \"PWD\": \"\\/home\\/user\\/Documents\\/code\\/atuin\", \"PYENV_DIR\": \"\\/home\\/user\\/Documents\\/code\\/atuin\", \"PYENV_HOOK_PATH\": \"\\/home\\/user\\/.pyenv\\/pyenv.d:\\/usr\\/local\\/etc\\/pyenv.d:\\/etc\\/pyenv.d:\\/usr\\/lib\\/pyenv\\/hooks:\\/home\\/user\\/.pyenv\\/plugins\\/pyenv-virtualenv\\/etc\\/pyenv.d\", \"PYENV_ROOT\": \"\\/home\\/user\\/.pyenv\", \"PYENV_SHELL\": \"bash\", \"PYENV_VERSION\": \"3.12.0\", \"QT_ACCESSIBILITY\": \"1\", \"QT_IM_MODULE\": \"ibus\", \"SESSION_MANAGER\": \"local\\/box:@\\/tmp\\/.ICE-unix\\/2452,unix\\/box:\\/tmp\\/.ICE-unix\\/2452\", \"SHELL\": \"\\/bin\\/bash\", \"SHLVL\": \"1\", \"SSH_AGENT_LAUNCHER\": \"gnome-keyring\", \"SSH_AUTH_SOCK\": \"\\/run\\/user\\/1000\\/keyring\\/ssh\", \"SSL_CERT_DIR\": \"\\/usr\\/lib\\/ssl\\/certs\", \"SSL_CERT_FILE\": \"\\/usr\\/lib\\/ssl\\/certs\\/ca-certificates.crt\", \"SYSTEMD_EXEC_PID\": \"2470\", \"TERM\": \"xterm-256color\", \"TERM_PROGRAM\": \"WezTerm\", \"TERM_PROGRAM_VERSION\": \"20240127-113634-bbcac864\", \"THREAD_SUBPROCS\": \"1\", \"USER\": \"user\", \"USERNAME\": \"user\", \"WAYLAND_DISPLAY\": \"wayland-0\", \"WEZTERM_CONFIG_DIR\": \"\\/home\\/user\\/.config\\/wezterm\", \"WEZTERM_CONFIG_FILE\": \"\\/home\\/user\\/.config\\/wezterm\\/wezterm.lua\", \"WEZTERM_EXECUTABLE\": \"\\/usr\\/bin\\/wezterm-gui\", \"WEZTERM_EXECUTABLE_DIR\": \"\\/usr\\/bin\", \"WEZTERM_PANE\": \"38\", \"WEZTERM_UNIX_SOCKET\": \"\\/run\\/user\\/1000\\/wezterm\\/gui-sock-196859\", \"XAUTHORITY\": \"\\/run\\/user\\/1000\\/.mutter-Xwaylandauth.T986H2\", \"XDG_CONFIG_DIRS\": \"\\/etc\\/xdg\\/xdg-ubuntu:\\/etc\\/xdg\", \"XDG_CURRENT_DESKTOP\": \"ubuntu:GNOME\", \"XDG_DATA_DIRS\": \"\\/usr\\/share\\/ubuntu:\\/usr\\/local\\/share\\/:\\/usr\\/share\\/:\\/var\\/lib\\/snapd\\/desktop\", \"XDG_MENU_PREFIX\": \"gnome-\", \"XDG_RUNTIME_DIR\": \"\\/run\\/user\\/1000\", \"XDG_SESSION_CLASS\": \"user\", \"XDG_SESSION_DESKTOP\": \"ubuntu\", \"XDG_SESSION_TYPE\": \"wayland\", \"XMODIFIERS\": \"@im=ibus\", \"XONSHRC\": \"\\/etc\\/xonsh\\/xonshrc:\\/home\\/user\\/.config\\/xonsh\\/rc.xsh:\\/home\\/user\\/.xonshrc\", \"XONSHRC_DIR\": \"\\/etc\\/xonsh\\/rc.d:\\/home\\/user\\/.config\\/xonsh\\/rc.d\", \"XONSH_CAPTURE_ALWAYS\": \"\", \"XONSH_CONFIG_DIR\": \"\\/home\\/user\\/.config\\/xonsh\", \"XONSH_DATA_DIR\": \"\\/home\\/user\\/.local\\/share\\/xonsh\", \"XONSH_INTERACTIVE\": \"1\", \"XONSH_LOGIN\": \"1\", \"XONSH_VERSION\": \"0.14.2\"}\n, \"locked\": false, \"sessionid\": \"de16af90-9148-4461-8df3-5b5659c6420d\", \"ts\": [1707193067.8615997, 1707193089.2513068]\n}\n\n}\n"
  },
  {
    "path": "crates/atuin-common/Cargo.toml",
    "content": "[package]\nname = \"atuin-common\"\nedition = \"2024\"\ndescription = \"common library for atuin\"\n\nrust-version = { workspace = true }\nversion = { workspace = true }\nauthors = { workspace = true }\nlicense = { workspace = true }\nhomepage = { workspace = true }\nrepository = { workspace = true }\n\n# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html\n\n[dependencies]\ntime = { workspace = true }\nserde = { workspace = true }\nuuid = { workspace = true }\ntyped-builder = { workspace = true }\neyre = { workspace = true }\nsqlx = { workspace = true }\nsemver = { workspace = true }\nthiserror = { workspace = true }\ndirectories = { workspace = true }\nsysinfo = \"0.30.7\"\nbase64 = { workspace = true }\ngetrandom = \"0.2\"\nrustls = { workspace = true }\n\n[dev-dependencies]\npretty_assertions = { workspace = true }\n"
  },
  {
    "path": "crates/atuin-common/src/api.rs",
    "content": "use semver::Version;\nuse serde::{Deserialize, Serialize};\nuse std::borrow::Cow;\nuse std::sync::LazyLock;\nuse time::OffsetDateTime;\n\n// the usage of X- has been deprecated for quite along time, it turns out\npub static ATUIN_HEADER_VERSION: &str = \"Atuin-Version\";\npub static ATUIN_CARGO_VERSION: &str = env!(\"CARGO_PKG_VERSION\");\n\npub static ATUIN_VERSION: LazyLock<Version> =\n    LazyLock::new(|| Version::parse(ATUIN_CARGO_VERSION).expect(\"failed to parse self semver\"));\n\n#[derive(Debug, Serialize, Deserialize)]\npub struct UserResponse {\n    pub username: String,\n}\n\n#[derive(Debug, Serialize, Deserialize)]\npub struct RegisterRequest {\n    pub email: String,\n    pub username: String,\n    pub password: String,\n}\n\n#[derive(Debug, Serialize, Deserialize)]\npub struct RegisterResponse {\n    pub session: String,\n}\n\n#[derive(Debug, Serialize, Deserialize)]\npub struct DeleteUserResponse {}\n\n#[derive(Debug, Serialize, Deserialize)]\npub struct ChangePasswordRequest {\n    pub current_password: String,\n    pub new_password: String,\n}\n\n#[derive(Debug, Serialize, Deserialize)]\npub struct ChangePasswordResponse {}\n\n#[derive(Debug, Serialize, Deserialize)]\npub struct LoginRequest {\n    pub username: String,\n    pub password: String,\n}\n\n#[derive(Debug, Serialize, Deserialize)]\npub struct LoginResponse {\n    pub session: String,\n}\n\n#[derive(Debug, Serialize, Deserialize)]\npub struct AddHistoryRequest {\n    pub id: String,\n    #[serde(with = \"time::serde::rfc3339\")]\n    pub timestamp: OffsetDateTime,\n    pub data: String,\n    pub hostname: String,\n}\n\n#[derive(Debug, Serialize, Deserialize)]\npub struct CountResponse {\n    pub count: i64,\n}\n\n#[derive(Debug, Serialize, Deserialize)]\npub struct SyncHistoryRequest {\n    #[serde(with = \"time::serde::rfc3339\")]\n    pub sync_ts: OffsetDateTime,\n    #[serde(with = \"time::serde::rfc3339\")]\n    pub history_ts: OffsetDateTime,\n    pub host: String,\n}\n\n#[derive(Debug, Serialize, Deserialize)]\npub struct SyncHistoryResponse {\n    pub history: Vec<String>,\n}\n\n#[derive(Debug, Serialize, Deserialize)]\npub struct ErrorResponse<'a> {\n    pub reason: Cow<'a, str>,\n}\n\n#[derive(Debug, Serialize, Deserialize)]\npub struct IndexResponse {\n    pub homage: String,\n    pub version: String,\n}\n\n#[derive(Debug, Serialize, Deserialize)]\npub struct StatusResponse {\n    pub count: i64,\n    pub username: String,\n    pub deleted: Vec<String>,\n\n    // These could/should also go on the index of the server\n    // However, we do not request the server index as a part of normal sync\n    // I'd rather slightly increase the size of this response, than add an extra HTTP request\n    pub page_size: i64, // max page size supported by the server\n    pub version: String,\n}\n\n#[derive(Debug, Serialize, Deserialize)]\npub struct DeleteHistoryRequest {\n    pub client_id: String,\n}\n\n#[derive(Debug, Serialize, Deserialize)]\npub struct MessageResponse {\n    pub message: String,\n}\n\n#[derive(Debug, Serialize, Deserialize)]\npub struct MeResponse {\n    pub username: String,\n}\n\n// Hub CLI authentication types\n\n/// Response from POST /auth/cli/code - generates a code for CLI auth\n#[derive(Debug, Serialize, Deserialize)]\npub struct CliCodeResponse {\n    pub code: String,\n}\n\n/// Response from GET /auth/cli/verify?code=<code> - polls for authorization\n#[derive(Debug, Serialize, Deserialize)]\npub struct CliVerifyResponse {\n    /// Session token, present only when authorization is complete\n    pub token: Option<String>,\n    pub success: Option<bool>,\n    pub error: Option<String>,\n}\n"
  },
  {
    "path": "crates/atuin-common/src/calendar.rs",
    "content": "// Calendar data\nuse serde::{Serialize, Deserialize};\n\npub enum TimePeriod {\n    YEAR,\n    MONTH,\n    DAY,\n}\n\n#[derive(Debug, Serialize, Deserialize)]\npub struct TimePeriodInfo {\n    pub count: u64,\n\n    // TODO: Use this for merkle tree magic\n    pub hash: String,\n}\n"
  },
  {
    "path": "crates/atuin-common/src/lib.rs",
    "content": "#![deny(unsafe_code)]\n\n/// Defines a new UUID type wrapper\nmacro_rules! new_uuid {\n    ($name:ident) => {\n        #[derive(\n            Debug,\n            Copy,\n            Clone,\n            PartialEq,\n            Eq,\n            Hash,\n            PartialOrd,\n            Ord,\n            serde::Serialize,\n            serde::Deserialize,\n        )]\n        #[serde(transparent)]\n        pub struct $name(pub Uuid);\n\n        impl<DB: sqlx::Database> sqlx::Type<DB> for $name\n        where\n            Uuid: sqlx::Type<DB>,\n        {\n            fn type_info() -> <DB as sqlx::Database>::TypeInfo {\n                Uuid::type_info()\n            }\n        }\n\n        impl<'r, DB: sqlx::Database> sqlx::Decode<'r, DB> for $name\n        where\n            Uuid: sqlx::Decode<'r, DB>,\n        {\n            fn decode(\n                value: DB::ValueRef<'r>,\n            ) -> std::result::Result<Self, sqlx::error::BoxDynError> {\n                Uuid::decode(value).map(Self)\n            }\n        }\n\n        impl<'q, DB: sqlx::Database> sqlx::Encode<'q, DB> for $name\n        where\n            Uuid: sqlx::Encode<'q, DB>,\n        {\n            fn encode_by_ref(\n                &self,\n                buf: &mut DB::ArgumentBuffer<'q>,\n            ) -> Result<sqlx::encode::IsNull, Box<dyn std::error::Error + Send + Sync + 'static>>\n            {\n                self.0.encode_by_ref(buf)\n            }\n        }\n    };\n}\n\npub mod api;\npub mod record;\npub mod shell;\npub mod tls;\npub mod utils;\n"
  },
  {
    "path": "crates/atuin-common/src/record.rs",
    "content": "use std::collections::HashMap;\n\nuse eyre::Result;\nuse serde::{Deserialize, Serialize};\nuse typed_builder::TypedBuilder;\nuse uuid::Uuid;\n\n#[derive(Clone, Debug, PartialEq)]\npub struct DecryptedData(pub Vec<u8>);\n\n#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]\npub struct EncryptedData {\n    pub data: String,\n    pub content_encryption_key: String,\n}\n\n#[derive(Debug, PartialEq, PartialOrd, Ord, Eq)]\npub struct Diff {\n    pub host: HostId,\n    pub tag: String,\n    pub local: Option<RecordIdx>,\n    pub remote: Option<RecordIdx>,\n}\n\n#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]\npub struct Host {\n    pub id: HostId,\n    pub name: String,\n}\n\nimpl Host {\n    pub fn new(id: HostId) -> Self {\n        Host {\n            id,\n            name: String::new(),\n        }\n    }\n}\n\nnew_uuid!(RecordId);\nnew_uuid!(HostId);\n\npub type RecordIdx = u64;\n\n/// A single record stored inside of our local database\n#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TypedBuilder)]\npub struct Record<Data> {\n    /// a unique ID\n    #[builder(default = RecordId(crate::utils::uuid_v7()))]\n    pub id: RecordId,\n\n    /// The integer record ID. This is only unique per (host, tag).\n    pub idx: RecordIdx,\n\n    /// The unique ID of the host.\n    // TODO(ellie): Optimize the storage here. We use a bunch of IDs, and currently store\n    // as strings. I would rather avoid normalization, so store as UUID binary instead of\n    // encoding to a string and wasting much more storage.\n    pub host: Host,\n\n    /// The creation time in nanoseconds since unix epoch\n    #[builder(default = time::OffsetDateTime::now_utc().unix_timestamp_nanos() as u64)]\n    pub timestamp: u64,\n\n    /// The version the data in the entry conforms to\n    // However we want to track versions for this tag, eg v2\n    pub version: String,\n\n    /// The type of data we are storing here. Eg, \"history\"\n    pub tag: String,\n\n    /// Some data. This can be anything you wish to store. Use the tag field to know how to handle it.\n    pub data: Data,\n}\n\n/// Extra data from the record that should be encoded in the data\n#[derive(Debug, Copy, Clone)]\npub struct AdditionalData<'a> {\n    pub id: &'a RecordId,\n    pub idx: &'a u64,\n    pub version: &'a str,\n    pub tag: &'a str,\n    pub host: &'a HostId,\n}\n\nimpl<Data> Record<Data> {\n    pub fn append(&self, data: Vec<u8>) -> Record<DecryptedData> {\n        Record::builder()\n            .host(self.host.clone())\n            .version(self.version.clone())\n            .idx(self.idx + 1)\n            .tag(self.tag.clone())\n            .data(DecryptedData(data))\n            .build()\n    }\n}\n\n/// An index representing the current state of the record stores\n/// This can be both remote, or local, and compared in either direction\n#[derive(Debug, Serialize, Deserialize)]\npub struct RecordStatus {\n    // A map of host -> tag -> max(idx)\n    pub hosts: HashMap<HostId, HashMap<String, RecordIdx>>,\n}\n\nimpl Default for RecordStatus {\n    fn default() -> Self {\n        Self::new()\n    }\n}\n\nimpl Extend<(HostId, String, RecordIdx)> for RecordStatus {\n    fn extend<T: IntoIterator<Item = (HostId, String, RecordIdx)>>(&mut self, iter: T) {\n        for (host, tag, tail_idx) in iter {\n            self.set_raw(host, tag, tail_idx);\n        }\n    }\n}\n\nimpl RecordStatus {\n    pub fn new() -> RecordStatus {\n        RecordStatus {\n            hosts: HashMap::new(),\n        }\n    }\n\n    /// Insert a new tail record into the store\n    pub fn set(&mut self, tail: Record<DecryptedData>) {\n        self.set_raw(tail.host.id, tail.tag, tail.idx)\n    }\n\n    pub fn set_raw(&mut self, host: HostId, tag: String, tail_id: RecordIdx) {\n        self.hosts.entry(host).or_default().insert(tag, tail_id);\n    }\n\n    pub fn get(&self, host: HostId, tag: String) -> Option<RecordIdx> {\n        self.hosts.get(&host).and_then(|v| v.get(&tag)).cloned()\n    }\n\n    /// Diff this index with another, likely remote index.\n    /// The two diffs can then be reconciled, and the optimal change set calculated\n    /// Returns a tuple, with (host, tag, Option(OTHER))\n    /// OTHER is set to the value of the idx on the other machine. If it is greater than our index,\n    /// then we need to do some downloading. If it is smaller, then we need to do some uploading\n    /// Note that we cannot upload if we are not the owner of the record store - hosts can only\n    /// write to their own store.\n    pub fn diff(&self, other: &Self) -> Vec<Diff> {\n        let mut ret = Vec::new();\n\n        // First, we check if other has everything that self has\n        for (host, tag_map) in self.hosts.iter() {\n            for (tag, idx) in tag_map.iter() {\n                match other.get(*host, tag.clone()) {\n                    // The other store is all up to date! No diff.\n                    Some(t) if t.eq(idx) => continue,\n\n                    // The other store does exist, and it is either ahead or behind us. A diff regardless\n                    Some(t) => ret.push(Diff {\n                        host: *host,\n                        tag: tag.clone(),\n                        local: Some(*idx),\n                        remote: Some(t),\n                    }),\n\n                    // The other store does not exist :O\n                    None => ret.push(Diff {\n                        host: *host,\n                        tag: tag.clone(),\n                        local: Some(*idx),\n                        remote: None,\n                    }),\n                };\n            }\n        }\n\n        // At this point, there is a single case we have not yet considered.\n        // If the other store knows of a tag that we are not yet aware of, then the diff will be missed\n\n        // account for that!\n        for (host, tag_map) in other.hosts.iter() {\n            for (tag, idx) in tag_map.iter() {\n                match self.get(*host, tag.clone()) {\n                    // If we have this host/tag combo, the comparison and diff will have already happened above\n                    Some(_) => continue,\n\n                    None => ret.push(Diff {\n                        host: *host,\n                        tag: tag.clone(),\n                        remote: Some(*idx),\n                        local: None,\n                    }),\n                };\n            }\n        }\n\n        // Stability is a nice property to have\n        ret.sort();\n        ret\n    }\n}\n\npub trait Encryption {\n    fn re_encrypt(\n        data: EncryptedData,\n        ad: AdditionalData,\n        old_key: &[u8; 32],\n        new_key: &[u8; 32],\n    ) -> Result<EncryptedData> {\n        let data = Self::decrypt(data, ad, old_key)?;\n        Ok(Self::encrypt(data, ad, new_key))\n    }\n    fn encrypt(data: DecryptedData, ad: AdditionalData, key: &[u8; 32]) -> EncryptedData;\n    fn decrypt(data: EncryptedData, ad: AdditionalData, key: &[u8; 32]) -> Result<DecryptedData>;\n}\n\nimpl Record<DecryptedData> {\n    pub fn encrypt<E: Encryption>(self, key: &[u8; 32]) -> Record<EncryptedData> {\n        let ad = AdditionalData {\n            id: &self.id,\n            version: &self.version,\n            tag: &self.tag,\n            host: &self.host.id,\n            idx: &self.idx,\n        };\n        Record {\n            data: E::encrypt(self.data, ad, key),\n            id: self.id,\n            host: self.host,\n            idx: self.idx,\n            timestamp: self.timestamp,\n            version: self.version,\n            tag: self.tag,\n        }\n    }\n}\n\nimpl Record<EncryptedData> {\n    pub fn decrypt<E: Encryption>(self, key: &[u8; 32]) -> Result<Record<DecryptedData>> {\n        let ad = AdditionalData {\n            id: &self.id,\n            version: &self.version,\n            tag: &self.tag,\n            host: &self.host.id,\n            idx: &self.idx,\n        };\n        Ok(Record {\n            data: E::decrypt(self.data, ad, key)?,\n            id: self.id,\n            host: self.host,\n            idx: self.idx,\n            timestamp: self.timestamp,\n            version: self.version,\n            tag: self.tag,\n        })\n    }\n\n    pub fn re_encrypt<E: Encryption>(\n        self,\n        old_key: &[u8; 32],\n        new_key: &[u8; 32],\n    ) -> Result<Record<EncryptedData>> {\n        let ad = AdditionalData {\n            id: &self.id,\n            version: &self.version,\n            tag: &self.tag,\n            host: &self.host.id,\n            idx: &self.idx,\n        };\n        Ok(Record {\n            data: E::re_encrypt(self.data, ad, old_key, new_key)?,\n            id: self.id,\n            host: self.host,\n            idx: self.idx,\n            timestamp: self.timestamp,\n            version: self.version,\n            tag: self.tag,\n        })\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use crate::record::{Host, HostId};\n\n    use super::{DecryptedData, Diff, Record, RecordStatus};\n    use pretty_assertions::assert_eq;\n\n    fn test_record() -> Record<DecryptedData> {\n        Record::builder()\n            .host(Host::new(HostId(crate::utils::uuid_v7())))\n            .version(\"v1\".into())\n            .tag(crate::utils::uuid_v7().simple().to_string())\n            .data(DecryptedData(vec![0, 1, 2, 3]))\n            .idx(0)\n            .build()\n    }\n\n    #[test]\n    fn record_index() {\n        let mut index = RecordStatus::new();\n        let record = test_record();\n\n        index.set(record.clone());\n\n        let tail = index.get(record.host.id, record.tag);\n\n        assert_eq!(\n            record.idx,\n            tail.expect(\"tail not in store\"),\n            \"tail in store did not match\"\n        );\n    }\n\n    #[test]\n    fn record_index_overwrite() {\n        let mut index = RecordStatus::new();\n        let record = test_record();\n        let child = record.append(vec![1, 2, 3]);\n\n        index.set(record.clone());\n        index.set(child.clone());\n\n        let tail = index.get(record.host.id, record.tag);\n\n        assert_eq!(\n            child.idx,\n            tail.expect(\"tail not in store\"),\n            \"tail in store did not match\"\n        );\n    }\n\n    #[test]\n    fn record_index_no_diff() {\n        // Here, they both have the same version and should have no diff\n\n        let mut index1 = RecordStatus::new();\n        let mut index2 = RecordStatus::new();\n\n        let record1 = test_record();\n\n        index1.set(record1.clone());\n        index2.set(record1);\n\n        let diff = index1.diff(&index2);\n\n        assert_eq!(0, diff.len(), \"expected empty diff\");\n    }\n\n    #[test]\n    fn record_index_single_diff() {\n        // Here, they both have the same stores, but one is ahead by a single record\n\n        let mut index1 = RecordStatus::new();\n        let mut index2 = RecordStatus::new();\n\n        let record1 = test_record();\n        let record2 = record1.append(vec![1, 2, 3]);\n\n        index1.set(record1);\n        index2.set(record2.clone());\n\n        let diff = index1.diff(&index2);\n\n        assert_eq!(1, diff.len(), \"expected single diff\");\n        assert_eq!(\n            diff[0],\n            Diff {\n                host: record2.host.id,\n                tag: record2.tag,\n                remote: Some(1),\n                local: Some(0)\n            }\n        );\n    }\n\n    #[test]\n    fn record_index_multi_diff() {\n        // A much more complex case, with a bunch more checks\n        let mut index1 = RecordStatus::new();\n        let mut index2 = RecordStatus::new();\n\n        let store1record1 = test_record();\n        let store1record2 = store1record1.append(vec![1, 2, 3]);\n\n        let store2record1 = test_record();\n        let store2record2 = store2record1.append(vec![1, 2, 3]);\n\n        let store3record1 = test_record();\n\n        let store4record1 = test_record();\n\n        // index1 only knows about the first two entries of the first two stores\n        index1.set(store1record1);\n        index1.set(store2record1);\n\n        // index2 is fully up to date with the first two stores, and knows of a third\n        index2.set(store1record2);\n        index2.set(store2record2);\n        index2.set(store3record1);\n\n        // index1 knows of a 4th store\n        index1.set(store4record1);\n\n        let diff1 = index1.diff(&index2);\n        let diff2 = index2.diff(&index1);\n\n        // both diffs the same length\n        assert_eq!(4, diff1.len());\n        assert_eq!(4, diff2.len());\n\n        dbg!(&diff1, &diff2);\n\n        // both diffs should be ALMOST the same. They will agree on which hosts and tags\n        // require updating, but the \"other\" value will not be the same.\n        let smol_diff_1: Vec<(HostId, String)> =\n            diff1.iter().map(|v| (v.host, v.tag.clone())).collect();\n        let smol_diff_2: Vec<(HostId, String)> =\n            diff1.iter().map(|v| (v.host, v.tag.clone())).collect();\n\n        assert_eq!(smol_diff_1, smol_diff_2);\n\n        // diffing with yourself = no diff\n        assert_eq!(index1.diff(&index1).len(), 0);\n        assert_eq!(index2.diff(&index2).len(), 0);\n    }\n}\n"
  },
  {
    "path": "crates/atuin-common/src/shell.rs",
    "content": "use std::{ffi::OsStr, path::Path, process::Command};\n\nuse serde::Serialize;\nuse sysinfo::{Process, System, get_current_pid};\nuse thiserror::Error;\n\n#[derive(PartialEq)]\npub enum Shell {\n    Sh,\n    Bash,\n    Fish,\n    Zsh,\n    Xonsh,\n    Nu,\n    Powershell,\n\n    Unknown,\n}\n\nimpl std::fmt::Display for Shell {\n    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n        let shell = match self {\n            Shell::Bash => \"bash\",\n            Shell::Fish => \"fish\",\n            Shell::Zsh => \"zsh\",\n            Shell::Nu => \"nu\",\n            Shell::Xonsh => \"xonsh\",\n            Shell::Sh => \"sh\",\n            Shell::Powershell => \"powershell\",\n\n            Shell::Unknown => \"unknown\",\n        };\n\n        write!(f, \"{shell}\")\n    }\n}\n\n#[derive(Debug, Error, Serialize)]\npub enum ShellError {\n    #[error(\"shell not supported\")]\n    NotSupported,\n\n    #[error(\"failed to execute shell command: {0}\")]\n    ExecError(String),\n}\n\nimpl Shell {\n    pub fn current() -> Shell {\n        let sys = System::new_all();\n\n        let process = sys\n            .process(get_current_pid().expect(\"Failed to get current PID\"))\n            .expect(\"Process with current pid does not exist\");\n\n        let parent = sys\n            .process(process.parent().expect(\"Atuin running with no parent!\"))\n            .expect(\"Process with parent pid does not exist\");\n\n        let shell = parent.name().trim().to_lowercase();\n        let shell = shell.strip_prefix('-').unwrap_or(&shell);\n\n        Shell::from_string(shell.to_string())\n    }\n\n    pub fn from_env() -> Shell {\n        std::env::var(\"ATUIN_SHELL\").map_or(Shell::Unknown, |shell| {\n            Shell::from_string(shell.trim().to_lowercase())\n        })\n    }\n\n    pub fn config_file(&self) -> Option<std::path::PathBuf> {\n        let mut path = if let Some(base) = directories::BaseDirs::new() {\n            base.home_dir().to_owned()\n        } else {\n            return None;\n        };\n\n        // TODO: handle all shells\n        match self {\n            Shell::Bash => path.push(\".bashrc\"),\n            Shell::Zsh => path.push(\".zshrc\"),\n            Shell::Fish => path.push(\".config/fish/config.fish\"),\n\n            _ => return None,\n        };\n\n        Some(path)\n    }\n\n    /// Best-effort attempt to determine the default shell\n    /// This implementation will be different across different platforms\n    /// Caller should ensure to handle Shell::Unknown correctly\n    pub fn default_shell() -> Result<Shell, ShellError> {\n        let sys = System::name().unwrap_or(\"\".to_string()).to_lowercase();\n\n        // TODO: Support Linux\n        // I'm pretty sure we can use /etc/passwd there, though there will probably be some issues\n        let path = if sys.contains(\"darwin\") {\n            // This works in my testing so far\n            Shell::Sh.run_interactive([\n                \"dscl localhost -read \\\"/Local/Default/Users/$USER\\\" shell | awk '{print $2}'\",\n            ])?\n        } else if cfg!(windows) {\n            return Ok(Shell::Powershell);\n        } else {\n            Shell::Sh.run_interactive([\"getent passwd $LOGNAME | cut -d: -f7\"])?\n        };\n\n        let path = Path::new(path.trim());\n        let shell = path.file_name();\n\n        if shell.is_none() {\n            return Err(ShellError::NotSupported);\n        }\n\n        Ok(Shell::from_string(\n            shell.unwrap().to_string_lossy().to_string(),\n        ))\n    }\n\n    pub fn from_string(name: String) -> Shell {\n        match name.as_str() {\n            \"bash\" => Shell::Bash,\n            \"fish\" => Shell::Fish,\n            \"zsh\" => Shell::Zsh,\n            \"xonsh\" => Shell::Xonsh,\n            \"nu\" => Shell::Nu,\n            \"sh\" => Shell::Sh,\n            \"powershell\" => Shell::Powershell,\n\n            _ => Shell::Unknown,\n        }\n    }\n\n    /// Returns true if the shell is posix-like\n    /// Note that while fish is not posix compliant, it behaves well enough for our current\n    /// featureset that this does not matter.\n    pub fn is_posixish(&self) -> bool {\n        matches!(self, Shell::Bash | Shell::Fish | Shell::Zsh)\n    }\n\n    pub fn run_interactive<I, S>(&self, args: I) -> Result<String, ShellError>\n    where\n        I: IntoIterator<Item = S>,\n        S: AsRef<OsStr>,\n    {\n        let shell = self.to_string();\n        let output = if self == &Self::Powershell {\n            Command::new(shell)\n                .args(args)\n                .output()\n                .map_err(|e| ShellError::ExecError(e.to_string()))?\n        } else {\n            Command::new(shell)\n                .arg(\"-ic\")\n                .args(args)\n                .output()\n                .map_err(|e| ShellError::ExecError(e.to_string()))?\n        };\n\n        Ok(String::from_utf8(output.stdout).unwrap())\n    }\n}\n\npub fn shell_name(parent: Option<&Process>) -> String {\n    let sys = System::new_all();\n\n    let parent = if let Some(parent) = parent {\n        parent\n    } else {\n        let process = sys\n            .process(get_current_pid().expect(\"Failed to get current PID\"))\n            .expect(\"Process with current pid does not exist\");\n\n        sys.process(process.parent().expect(\"Atuin running with no parent!\"))\n            .expect(\"Process with parent pid does not exist\")\n    };\n\n    let shell = parent.name().trim().to_lowercase();\n    let shell = shell.strip_prefix('-').unwrap_or(&shell);\n\n    shell.to_string()\n}\n"
  },
  {
    "path": "crates/atuin-common/src/tls.rs",
    "content": "use std::sync::Once;\n\nstatic INIT: Once = Once::new();\n\n/// Ensure the rustls crypto provider (ring) is installed.\n///\n/// Must be called before creating any reqwest clients. Safe to call\n/// multiple times — only the first call installs the provider.\npub fn ensure_crypto_provider() {\n    INIT.call_once(|| {\n        rustls::crypto::ring::default_provider()\n            .install_default()\n            .expect(\"Failed to install rustls crypto provider\");\n    });\n}\n"
  },
  {
    "path": "crates/atuin-common/src/utils.rs",
    "content": "use std::borrow::Cow;\nuse std::env;\nuse std::path::PathBuf;\n\nuse eyre::{Result, eyre};\n\nuse base64::prelude::{BASE64_URL_SAFE_NO_PAD, Engine};\nuse getrandom::getrandom;\nuse uuid::Uuid;\n\n/// Generate N random bytes, using a cryptographically secure source\npub fn crypto_random_bytes<const N: usize>() -> [u8; N] {\n    // rand say they are in principle safe for crypto purposes, but that it is perhaps a better\n    // idea to use getrandom for things such as passwords.\n    let mut ret = [0u8; N];\n\n    getrandom(&mut ret).expect(\"Failed to generate random bytes!\");\n\n    ret\n}\n\n/// Generate N random bytes using a cryptographically secure source, return encoded as a string\npub fn crypto_random_string<const N: usize>() -> String {\n    let bytes = crypto_random_bytes::<N>();\n\n    // We only use this to create a random string, and won't be reversing it to find the original\n    // data - no padding is OK there. It may be in URLs.\n    BASE64_URL_SAFE_NO_PAD.encode(bytes)\n}\n\npub fn uuid_v7() -> Uuid {\n    Uuid::now_v7()\n}\n\npub fn uuid_v4() -> String {\n    Uuid::new_v4().as_simple().to_string()\n}\n\npub fn has_git_dir(path: &str) -> bool {\n    let mut gitdir = PathBuf::from(path);\n    gitdir.push(\".git\");\n\n    gitdir.exists()\n}\n\n// detect if any parent dir has a git repo in it\n// I really don't want to bring in libgit for something simple like this\n// If we start to do anything more advanced, then perhaps\npub fn in_git_repo(path: &str) -> Option<PathBuf> {\n    let mut gitdir = PathBuf::from(path);\n\n    while gitdir.parent().is_some() && !has_git_dir(gitdir.to_str().unwrap()) {\n        gitdir.pop();\n    }\n\n    // No parent? then we hit root, finding no git\n    if gitdir.parent().is_some() {\n        return Some(gitdir);\n    }\n\n    None\n}\n\n// TODO: more reliable, more tested\n// I don't want to use ProjectDirs, it puts config in awkward places on\n// mac. Data too. Seems to be more intended for GUI apps.\n\npub fn home_dir() -> PathBuf {\n    directories::BaseDirs::new()\n        .map(|d| d.home_dir().to_path_buf())\n        .expect(\"could not determine home directory\")\n}\n\npub fn config_dir() -> PathBuf {\n    let config_dir =\n        std::env::var(\"XDG_CONFIG_HOME\").map_or_else(|_| home_dir().join(\".config\"), PathBuf::from);\n    config_dir.join(\"atuin\")\n}\n\npub fn data_dir() -> PathBuf {\n    let data_dir = std::env::var(\"XDG_DATA_HOME\")\n        .map_or_else(|_| home_dir().join(\".local\").join(\"share\"), PathBuf::from);\n\n    data_dir.join(\"atuin\")\n}\n\npub fn runtime_dir() -> PathBuf {\n    std::env::var(\"XDG_RUNTIME_DIR\").map_or_else(|_| data_dir(), PathBuf::from)\n}\n\npub fn logs_dir() -> PathBuf {\n    home_dir().join(\".atuin\").join(\"logs\")\n}\n\npub fn dotfiles_cache_dir() -> PathBuf {\n    // In most cases, this will be  ~/.local/share/atuin/dotfiles/cache\n    let data_dir = std::env::var(\"XDG_DATA_HOME\")\n        .map_or_else(|_| home_dir().join(\".local\").join(\"share\"), PathBuf::from);\n\n    data_dir.join(\"atuin\").join(\"dotfiles\").join(\"cache\")\n}\n\npub fn get_current_dir() -> String {\n    // Prefer PWD environment variable over cwd if available to better support symbolic links\n    match env::var(\"PWD\") {\n        Ok(v) => v,\n        Err(_) => match env::current_dir() {\n            Ok(dir) => dir.display().to_string(),\n            Err(_) => String::from(\"\"),\n        },\n    }\n}\n\npub fn broken_symlink<P: Into<PathBuf>>(path: P) -> bool {\n    let path = path.into();\n    path.is_symlink() && !path.exists()\n}\n\n/// Extension trait for anything that can behave like a string to make it easy to escape control\n/// characters.\n///\n/// Intended to help prevent control characters being printed and interpreted by the terminal when\n/// printing history as well as to ensure the commands that appear in the interactive search\n/// reflect the actual command run rather than just the printable characters.\npub trait Escapable: AsRef<str> {\n    fn escape_control(&self) -> Cow<'_, str> {\n        if !self.as_ref().contains(|c: char| c.is_ascii_control()) {\n            self.as_ref().into()\n        } else {\n            let mut remaining = self.as_ref();\n            // Not a perfect way to reserve space but should reduce the allocations\n            let mut buf = String::with_capacity(remaining.len());\n            while let Some(i) = remaining.find(|c: char| c.is_ascii_control()) {\n                // safe to index with `..i`, `i` and `i+1..` as part[i] is a single byte ascii char\n                buf.push_str(&remaining[..i]);\n                buf.push('^');\n                buf.push(match remaining.as_bytes()[i] {\n                    0x7F => '?',\n                    code => char::from_u32(u32::from(code) + 64).unwrap(),\n                });\n                remaining = &remaining[i + 1..];\n            }\n            buf.push_str(remaining);\n            buf.into()\n        }\n    }\n}\n\npub fn unquote(s: &str) -> Result<String> {\n    if s.chars().count() < 2 {\n        return Err(eyre!(\"not enough chars\"));\n    }\n\n    let quote = s.chars().next().unwrap();\n\n    // not quoted, do nothing\n    if quote != '\"' && quote != '\\'' && quote != '`' {\n        return Ok(s.to_string());\n    }\n\n    if s.chars().last().unwrap() != quote {\n        return Err(eyre!(\"unexpected eof, quotes do not match\"));\n    }\n\n    // removes quote characters\n    // the sanity checks performed above ensure that the quotes will be ASCII and this will not\n    // panic\n    let s = &s[1..s.len() - 1];\n\n    Ok(s.to_string())\n}\n\nimpl<T: AsRef<str>> Escapable for T {}\n\n#[allow(unsafe_code)]\n#[cfg(test)]\nmod tests {\n    use pretty_assertions::assert_ne;\n\n    use super::*;\n\n    use std::collections::HashSet;\n\n    #[cfg(not(windows))]\n    #[test]\n    fn test_dirs() {\n        // these tests need to be run sequentially to prevent race condition\n        test_config_dir_xdg();\n        test_config_dir();\n        test_data_dir_xdg();\n        test_data_dir();\n    }\n\n    #[cfg(not(windows))]\n    fn test_config_dir_xdg() {\n        // TODO: Audit that the environment access only happens in single-threaded code.\n        unsafe { env::remove_var(\"HOME\") };\n        // TODO: Audit that the environment access only happens in single-threaded code.\n        unsafe { env::set_var(\"XDG_CONFIG_HOME\", \"/home/user/custom_config\") };\n        assert_eq!(\n            config_dir(),\n            PathBuf::from(\"/home/user/custom_config/atuin\")\n        );\n        // TODO: Audit that the environment access only happens in single-threaded code.\n        unsafe { env::remove_var(\"XDG_CONFIG_HOME\") };\n    }\n\n    #[cfg(not(windows))]\n    fn test_config_dir() {\n        // TODO: Audit that the environment access only happens in single-threaded code.\n        unsafe { env::set_var(\"HOME\", \"/home/user\") };\n        // TODO: Audit that the environment access only happens in single-threaded code.\n        unsafe { env::remove_var(\"XDG_CONFIG_HOME\") };\n\n        assert_eq!(config_dir(), PathBuf::from(\"/home/user/.config/atuin\"));\n\n        // TODO: Audit that the environment access only happens in single-threaded code.\n        unsafe { env::remove_var(\"HOME\") };\n    }\n\n    #[cfg(not(windows))]\n    fn test_data_dir_xdg() {\n        // TODO: Audit that the environment access only happens in single-threaded code.\n        unsafe { env::remove_var(\"HOME\") };\n        // TODO: Audit that the environment access only happens in single-threaded code.\n        unsafe { env::set_var(\"XDG_DATA_HOME\", \"/home/user/custom_data\") };\n        assert_eq!(data_dir(), PathBuf::from(\"/home/user/custom_data/atuin\"));\n        // TODO: Audit that the environment access only happens in single-threaded code.\n        unsafe { env::remove_var(\"XDG_DATA_HOME\") };\n    }\n\n    #[cfg(not(windows))]\n    fn test_data_dir() {\n        // TODO: Audit that the environment access only happens in single-threaded code.\n        unsafe { env::set_var(\"HOME\", \"/home/user\") };\n        // TODO: Audit that the environment access only happens in single-threaded code.\n        unsafe { env::remove_var(\"XDG_DATA_HOME\") };\n        assert_eq!(data_dir(), PathBuf::from(\"/home/user/.local/share/atuin\"));\n        // TODO: Audit that the environment access only happens in single-threaded code.\n        unsafe { env::remove_var(\"HOME\") };\n    }\n\n    #[test]\n    fn uuid_is_unique() {\n        let how_many: usize = 1000000;\n\n        // for peace of mind\n        let mut uuids: HashSet<Uuid> = HashSet::with_capacity(how_many);\n\n        // there will be many in the same millisecond\n        for _ in 0..how_many {\n            let uuid = uuid_v7();\n            uuids.insert(uuid);\n        }\n\n        assert_eq!(uuids.len(), how_many);\n    }\n\n    #[test]\n    fn escape_control_characters() {\n        use super::Escapable;\n        // CSI colour sequence\n        assert_eq!(\"\\x1b[31mfoo\".escape_control(), \"^[[31mfoo\");\n\n        // Tabs count as control chars\n        assert_eq!(\"foo\\tbar\".escape_control(), \"foo^Ibar\");\n\n        // space is in control char range but should be excluded\n        assert_eq!(\"two words\".escape_control(), \"two words\");\n\n        // unicode multi-byte characters\n        let s = \"🐢\\x1b[32m🦀\";\n        assert_eq!(s.escape_control(), s.replace(\"\\x1b\", \"^[\"));\n    }\n\n    #[test]\n    fn escape_no_control_characters() {\n        use super::Escapable as _;\n        assert!(matches!(\n            \"no control characters\".escape_control(),\n            Cow::Borrowed(_)\n        ));\n        assert!(matches!(\n            \"with \\x1b[31mcontrol\\x1b[0m characters\".escape_control(),\n            Cow::Owned(_)\n        ));\n    }\n\n    #[test]\n    fn dumb_random_test() {\n        // Obviously not a test of randomness, but make sure we haven't made some\n        // catastrophic error\n\n        assert_ne!(crypto_random_string::<1>(), crypto_random_string::<1>());\n        assert_ne!(crypto_random_string::<2>(), crypto_random_string::<2>());\n        assert_ne!(crypto_random_string::<4>(), crypto_random_string::<4>());\n        assert_ne!(crypto_random_string::<8>(), crypto_random_string::<8>());\n        assert_ne!(crypto_random_string::<16>(), crypto_random_string::<16>());\n        assert_ne!(crypto_random_string::<32>(), crypto_random_string::<32>());\n    }\n}\n"
  },
  {
    "path": "crates/atuin-daemon/Cargo.toml",
    "content": "[package]\nname = \"atuin-daemon\"\nedition = \"2024\"\nversion = { workspace = true }\ndescription = \"The daemon crate for Atuin\"\n\nauthors.workspace = true\nrust-version.workspace = true\nlicense.workspace = true\nhomepage.workspace = true\nrepository.workspace = true\nreadme.workspace = true\n\n# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html\n\n[dependencies]\natuin-client = { path = \"../atuin-client\", version = \"18.13.3\" }\natuin-common = { path = \"../atuin-common\", version = \"18.13.3\" }\natuin-dotfiles = { path = \"../atuin-dotfiles\", version = \"18.13.3\" }\natuin-history = { path = \"../atuin-history\", version = \"18.13.3\" }\n\ntime = { workspace = true }\nuuid = { workspace = true }\ntokio = { workspace = true }\ntower = { workspace = true }\neyre = { workspace = true }\ntracing = { workspace = true }\ntracing-subscriber = { workspace = true }\n\ndashmap = \"6.1.0\"\nlasso = { version = \"0.7\", features = [\"multi-threaded\"] }\ntonic-types = \"0.14\"\ntonic = \"0.14\"\ntonic-prost = \"0.14\"\nprost = \"0.14\"\nprost-types = \"0.14\"\ntokio-stream = { version = \"0.1.14\", features = [\"net\"] }\nhyper-util = \"0.1\"\n\nrand.workspace = true\natuin-nucleo = { workspace = true }\n\n\n[target.'cfg(target_os = \"linux\")'.dependencies]\nlistenfd = \"1.0.1\"\n\n[dev-dependencies]\ntempfile = { workspace = true }\n\n[build-dependencies]\nprotox = \"0.9\"\ntonic-build = \"0.14\"\ntonic-prost-build = \"0.14\"\n"
  },
  {
    "path": "crates/atuin-daemon/build.rs",
    "content": "use std::{env, fs, path::PathBuf};\n\nuse protox::prost::Message;\n\nfn main() -> std::io::Result<()> {\n    let proto_paths = [\n        \"proto/history.proto\",\n        \"proto/search.proto\",\n        \"proto/control.proto\",\n    ];\n    let proto_include_dirs = [\"proto\"];\n\n    let file_descriptors = protox::compile(proto_paths, proto_include_dirs).unwrap();\n\n    let file_descriptor_path = PathBuf::from(env::var_os(\"OUT_DIR\").expect(\"OUT_DIR not set\"))\n        .join(\"file_descriptor_set.bin\");\n    fs::write(&file_descriptor_path, file_descriptors.encode_to_vec()).unwrap();\n\n    tonic_prost_build::configure()\n        .build_server(true)\n        .file_descriptor_set_path(&file_descriptor_path)\n        .skip_protoc_run()\n        .compile_protos(&proto_paths, &proto_include_dirs)\n}\n"
  },
  {
    "path": "crates/atuin-daemon/proto/control.proto",
    "content": "syntax = \"proto3\";\npackage control;\n\n// The Control service allows external processes (CLI commands, etc.)\n// to inject events into the running daemon.\nservice Control {\n  // Send an event to the daemon's event bus\n  rpc SendEvent(SendEventRequest) returns (SendEventResponse);\n}\n\nmessage SendEventRequest {\n  oneof event {\n    // History was pruned - search index needs full rebuild\n    HistoryPrunedEvent history_pruned = 1;\n\n    // Specific history items were deleted\n    HistoryDeletedEvent history_deleted = 2;\n\n    // Request immediate sync\n    ForceSyncEvent force_sync = 3;\n\n    // Settings have changed, reload if needed\n    SettingsReloadedEvent settings_reloaded = 4;\n\n    // Request graceful shutdown\n    ShutdownEvent shutdown = 5;\n\n    // History was rebuilt - search index needs full rebuild\n    HistoryRebuiltEvent history_rebuilt = 6;\n  }\n}\n\nmessage SendEventResponse {\n  // Empty on success; errors come through gRPC status\n}\n\n// Individual event message types\n\nmessage HistoryPrunedEvent {\n  // No fields needed - just signals that pruning happened\n}\n\nmessage HistoryRebuiltEvent {\n  // No fields needed - just signals that rebuilding happened\n}\n\nmessage HistoryDeletedEvent {\n  // IDs of deleted history items (UUIDs as strings)\n  repeated string ids = 1;\n}\n\nmessage ForceSyncEvent {\n  // No fields needed - just triggers sync\n}\n\nmessage SettingsReloadedEvent {\n  // No fields needed - components should re-read settings\n}\n\nmessage ShutdownEvent {\n  // No fields needed - triggers graceful shutdown\n}\n"
  },
  {
    "path": "crates/atuin-daemon/proto/history.proto",
    "content": "syntax = \"proto3\";\npackage history;\n\nmessage StartHistoryRequest {\n  // If people are still using my software in ~530 years, they can figure out a u128 migration\n  uint64 timestamp = 1; // nanosecond unix epoch\n  string command = 2;\n  string cwd = 3;\n  string session = 4;\n  string hostname = 5;\n  string author = 6;\n  string intent = 7;\n}\n\nmessage EndHistoryRequest {\n  string id = 1;\n  int64 exit = 2;\n  uint64 duration = 3;\n}\n\nmessage StartHistoryReply {\n  string id = 1;\n  string version = 2;\n  uint32 protocol = 3;\n}\n\nmessage EndHistoryReply {\n  string id = 1;\n  uint64 idx = 2;\n  string version = 3;\n  uint32 protocol = 4;\n}\n\nmessage StatusRequest {}\n\nmessage StatusReply {\n  bool healthy = 1;\n  string version = 2;\n  uint32 pid = 3;\n  uint32 protocol = 4;\n}\n\nmessage ShutdownRequest {}\n\nmessage ShutdownReply {\n  bool accepted = 1;\n}\n\nservice History {\n  rpc StartHistory(StartHistoryRequest) returns (StartHistoryReply);\n  rpc EndHistory(EndHistoryRequest) returns (EndHistoryReply);\n  rpc Status(StatusRequest) returns (StatusReply);\n  rpc Shutdown(ShutdownRequest) returns (ShutdownReply);\n}\n"
  },
  {
    "path": "crates/atuin-daemon/proto/search.proto",
    "content": "syntax = \"proto3\";\npackage search;\n\nenum FilterMode {\n  GLOBAL = 0;\n  HOST = 1;\n  SESSION = 2;\n  DIRECTORY = 3;\n  WORKSPACE = 4;\n  SESSION_PRELOAD = 5;\n}\n\nmessage SearchContext {\n  string session_id = 1;\n  string cwd = 2;\n  string hostname = 3;\n  string host_id = 4;\n  optional string git_root = 5;\n}\n\nmessage SearchRequest {\n  string query = 1;\n  uint64 query_id = 2;  // Incrementing ID to match responses to queries\n  FilterMode filter_mode = 3;\n  SearchContext context = 4;\n}\n\nmessage SearchResponse {\n  uint64 query_id = 1; // Echo back the query ID\n  repeated bytes ids = 2;\n}\n\nservice Search {\n  rpc Search(stream SearchRequest) returns (stream SearchResponse);\n}\n"
  },
  {
    "path": "crates/atuin-daemon/src/client.rs",
    "content": "use atuin_client::database::Context;\nuse atuin_client::settings::{FilterMode, Settings};\nuse eyre::{Context as EyreContext, Result};\n#[cfg(windows)]\nuse tokio::net::TcpStream;\nuse tonic::Code;\nuse tonic::transport::{Channel, Endpoint, Uri};\nuse tower::service_fn;\n\nuse hyper_util::rt::TokioIo;\n\n#[cfg(unix)]\nuse tokio::net::UnixStream;\n\nuse atuin_client::history::History;\nuse tracing::{Level, instrument, span};\n\nuse crate::control::HistoryRebuiltEvent;\nuse crate::control::{\n    ForceSyncEvent, HistoryDeletedEvent, HistoryPrunedEvent, SendEventRequest,\n    SettingsReloadedEvent, ShutdownEvent, control_client::ControlClient as ControlServiceClient,\n};\nuse crate::events::DaemonEvent;\nuse crate::history::{\n    EndHistoryReply, EndHistoryRequest, ShutdownRequest, StartHistoryReply, StartHistoryRequest,\n    StatusReply, StatusRequest, history_client::HistoryClient as HistoryServiceClient,\n};\nuse crate::search::{\n    FilterMode as RpcFilterMode, SearchContext as RpcSearchContext, SearchRequest, SearchResponse,\n    search_client::SearchClient as SearchServiceClient,\n};\n\npub struct HistoryClient {\n    client: HistoryServiceClient<Channel>,\n}\n\n#[derive(Clone, Copy, Debug, Eq, PartialEq)]\npub enum DaemonClientErrorKind {\n    Connect,\n    Unavailable,\n    Unimplemented,\n    Other,\n}\n\n#[must_use]\npub fn classify_error(error: &eyre::Report) -> DaemonClientErrorKind {\n    for cause in error.chain() {\n        if cause.downcast_ref::<tonic::transport::Error>().is_some() {\n            return DaemonClientErrorKind::Connect;\n        }\n\n        if let Some(status) = cause.downcast_ref::<tonic::Status>() {\n            return match status.code() {\n                Code::Unavailable => DaemonClientErrorKind::Unavailable,\n                Code::Unimplemented => DaemonClientErrorKind::Unimplemented,\n                _ => DaemonClientErrorKind::Other,\n            };\n        }\n    }\n\n    DaemonClientErrorKind::Other\n}\n\n// Wrap the grpc client\nimpl HistoryClient {\n    #[cfg(unix)]\n    pub async fn new(path: String) -> Result<Self> {\n        use eyre::Context;\n\n        let log_path = path.clone();\n        let channel = Endpoint::try_from(\"http://atuin_local_daemon:0\")?\n            .connect_with_connector(service_fn(move |_: Uri| {\n                let path = path.clone();\n\n                async move {\n                    Ok::<_, std::io::Error>(TokioIo::new(UnixStream::connect(path.clone()).await?))\n                }\n            }))\n            .await\n            .wrap_err_with(|| {\n                format!(\n                    \"failed to connect to local atuin daemon at {}. Is it running?\",\n                    &log_path\n                )\n            })?;\n\n        let client = HistoryServiceClient::new(channel);\n\n        Ok(HistoryClient { client })\n    }\n\n    #[cfg(not(unix))]\n    pub async fn new(port: u64) -> Result<Self> {\n        let channel = Endpoint::try_from(\"http://atuin_local_daemon:0\")?\n            .connect_with_connector(service_fn(move |_: Uri| {\n                let url = format!(\"127.0.0.1:{port}\");\n\n                async move {\n                    Ok::<_, std::io::Error>(TokioIo::new(TcpStream::connect(url.clone()).await?))\n                }\n            }))\n            .await\n            .wrap_err_with(|| {\n                format!(\n                    \"failed to connect to local atuin daemon at 127.0.0.1:{port}. Is it running?\"\n                )\n            })?;\n\n        let client = HistoryServiceClient::new(channel);\n\n        Ok(HistoryClient { client })\n    }\n\n    pub async fn start_history(&mut self, h: History) -> Result<StartHistoryReply> {\n        let req = StartHistoryRequest {\n            command: h.command,\n            cwd: h.cwd,\n            hostname: h.hostname,\n            session: h.session,\n            timestamp: h.timestamp.unix_timestamp_nanos() as u64,\n            author: h.author,\n            intent: h.intent.unwrap_or_default(),\n        };\n\n        Ok(self.client.start_history(req).await?.into_inner())\n    }\n\n    pub async fn end_history(\n        &mut self,\n        id: String,\n        duration: u64,\n        exit: i64,\n    ) -> Result<EndHistoryReply> {\n        let req = EndHistoryRequest { id, duration, exit };\n\n        Ok(self.client.end_history(req).await?.into_inner())\n    }\n\n    pub async fn status(&mut self) -> Result<StatusReply> {\n        Ok(self.client.status(StatusRequest {}).await?.into_inner())\n    }\n\n    pub async fn shutdown(&mut self) -> Result<bool> {\n        let resp = self.client.shutdown(ShutdownRequest {}).await?.into_inner();\n        Ok(resp.accepted)\n    }\n}\n\npub struct SearchClient {\n    client: SearchServiceClient<Channel>,\n}\n\nimpl SearchClient {\n    #[cfg(unix)]\n    pub async fn new(path: String) -> Result<Self> {\n        let log_path = path.clone();\n        let channel = Endpoint::try_from(\"http://atuin_local_daemon:0\")?\n            .connect_with_connector(service_fn(move |_: Uri| {\n                let path = path.clone();\n\n                async move {\n                    Ok::<_, std::io::Error>(TokioIo::new(UnixStream::connect(path.clone()).await?))\n                }\n            }))\n            .await\n            .wrap_err_with(|| {\n                format!(\n                    \"failed to connect to local atuin daemon at {}. Is it running?\",\n                    &log_path\n                )\n            })?;\n\n        let client = SearchServiceClient::new(channel);\n\n        Ok(SearchClient { client })\n    }\n\n    #[cfg(not(unix))]\n    pub async fn new(port: u64) -> Result<Self> {\n        let channel = Endpoint::try_from(\"http://atuin_local_daemon:0\")?\n            .connect_with_connector(service_fn(move |_: Uri| {\n                let url = format!(\"127.0.0.1:{port}\");\n\n                async move {\n                    Ok::<_, std::io::Error>(TokioIo::new(TcpStream::connect(url.clone()).await?))\n                }\n            }))\n            .await\n            .wrap_err_with(|| {\n                format!(\n                    \"failed to connect to local atuin daemon at 127.0.0.1:{port}. Is it running?\"\n                )\n            })?;\n\n        let client = SearchServiceClient::new(channel);\n\n        Ok(SearchClient { client })\n    }\n\n    #[instrument(skip_all, level = Level::TRACE, name = \"daemon_client_search\", fields(query = %query, query_id = query_id))]\n    pub async fn search(\n        &mut self,\n        query: String,\n        query_id: u64,\n        filter_mode: FilterMode,\n        context: Option<Context>,\n    ) -> Result<tonic::Streaming<SearchResponse>> {\n        let request = SearchRequest {\n            query,\n            query_id,\n            filter_mode: RpcFilterMode::from(filter_mode).into(),\n            context: context.map(RpcSearchContext::from),\n        };\n        let request_stream = tokio_stream::once(request);\n        let response = span!(Level::TRACE, \"daemon_client_search.request\")\n            .in_scope(async || self.client.search(request_stream).await)\n            .await?;\n\n        Ok(response.into_inner())\n    }\n}\n\nimpl From<FilterMode> for RpcFilterMode {\n    fn from(filter_mode: FilterMode) -> Self {\n        match filter_mode {\n            FilterMode::Global => RpcFilterMode::Global,\n            FilterMode::Host => RpcFilterMode::Host,\n            FilterMode::Session => RpcFilterMode::Session,\n            FilterMode::Directory => RpcFilterMode::Directory,\n            FilterMode::Workspace => RpcFilterMode::Workspace,\n            FilterMode::SessionPreload => RpcFilterMode::SessionPreload,\n        }\n    }\n}\n\nimpl From<Context> for RpcSearchContext {\n    fn from(context: Context) -> Self {\n        RpcSearchContext {\n            session_id: context.session,\n            cwd: context.cwd,\n            hostname: context.hostname,\n            host_id: context.host_id,\n            git_root: context\n                .git_root\n                .map(|path| path.to_string_lossy().to_string()),\n        }\n    }\n}\n\n// ============================================================================\n// Control Client\n// ============================================================================\n\n/// Client for the Control gRPC service.\n///\n/// Used to inject events into a running daemon from external processes.\npub struct ControlClient {\n    client: ControlServiceClient<Channel>,\n}\n\nimpl ControlClient {\n    /// Connect to the daemon's control service.\n    #[cfg(unix)]\n    pub async fn new(path: String) -> Result<Self> {\n        let log_path = path.clone();\n        let channel = Endpoint::try_from(\"http://atuin_local_daemon:0\")?\n            .connect_with_connector(service_fn(move |_: Uri| {\n                let path = path.clone();\n\n                async move {\n                    Ok::<_, std::io::Error>(TokioIo::new(UnixStream::connect(path.clone()).await?))\n                }\n            }))\n            .await\n            .wrap_err_with(|| {\n                format!(\n                    \"failed to connect to local atuin daemon at {}. Is it running?\",\n                    &log_path\n                )\n            })?;\n\n        let client = ControlServiceClient::new(channel);\n\n        Ok(ControlClient { client })\n    }\n\n    /// Connect to the daemon's control service.\n    #[cfg(not(unix))]\n    pub async fn new(port: u64) -> Result<Self> {\n        let channel = Endpoint::try_from(\"http://atuin_local_daemon:0\")?\n            .connect_with_connector(service_fn(move |_: Uri| {\n                let url = format!(\"127.0.0.1:{port}\");\n\n                async move {\n                    Ok::<_, std::io::Error>(TokioIo::new(TcpStream::connect(url.clone()).await?))\n                }\n            }))\n            .await\n            .wrap_err_with(|| {\n                format!(\n                    \"failed to connect to local atuin daemon at 127.0.0.1:{port}. Is it running?\"\n                )\n            })?;\n\n        let client = ControlServiceClient::new(channel);\n\n        Ok(ControlClient { client })\n    }\n\n    /// Connect using settings.\n    #[cfg(unix)]\n    pub async fn from_settings(settings: &Settings) -> Result<Self> {\n        Self::new(settings.daemon.socket_path.clone()).await\n    }\n\n    /// Connect using settings.\n    #[cfg(not(unix))]\n    pub async fn from_settings(settings: &Settings) -> Result<Self> {\n        Self::new(settings.daemon.tcp_port).await\n    }\n\n    /// Send an event to the daemon.\n    pub async fn send_event(&mut self, event: DaemonEvent) -> Result<()> {\n        let proto_event = daemon_event_to_proto(event);\n        let request = SendEventRequest {\n            event: Some(proto_event),\n        };\n        self.client.send_event(request).await?;\n        Ok(())\n    }\n}\n\n/// Convert a daemon event to its proto representation.\nfn daemon_event_to_proto(event: DaemonEvent) -> crate::control::send_event_request::Event {\n    use crate::control::send_event_request::Event;\n\n    match event {\n        DaemonEvent::HistoryPruned => Event::HistoryPruned(HistoryPrunedEvent {}),\n        DaemonEvent::HistoryRebuilt => Event::HistoryRebuilt(HistoryRebuiltEvent {}),\n        DaemonEvent::HistoryDeleted { ids } => Event::HistoryDeleted(HistoryDeletedEvent {\n            ids: ids.into_iter().map(|id| id.0).collect(),\n        }),\n        DaemonEvent::ForceSync => Event::ForceSync(ForceSyncEvent {}),\n        DaemonEvent::SettingsReloaded => Event::SettingsReloaded(SettingsReloadedEvent {}),\n        DaemonEvent::ShutdownRequested => Event::Shutdown(ShutdownEvent {}),\n        // These events are internal and not sent via the control service\n        DaemonEvent::HistoryStarted(_)\n        | DaemonEvent::HistoryEnded(_)\n        | DaemonEvent::RecordsAdded(_)\n        | DaemonEvent::SyncCompleted { .. }\n        | DaemonEvent::SyncFailed { .. } => {\n            // Use shutdown as a fallback, though this shouldn't happen\n            tracing::warn!(\"attempted to send internal event via control service\");\n            Event::Shutdown(ShutdownEvent {})\n        }\n    }\n}\n\n// ============================================================================\n// Convenience Functions\n// ============================================================================\n\n/// Emit an event to the daemon.\n///\n/// This is a fire-and-forget helper for sending events to the daemon from\n/// external processes like CLI commands. If the daemon isn't running, this\n/// will silently succeed (returns Ok).\n///\n/// # Example\n///\n/// ```ignore\n/// // After pruning history\n/// emit_event(DaemonEvent::HistoryPruned).await?;\n///\n/// // After deleting specific history items\n/// emit_event(DaemonEvent::HistoryDeleted { ids: vec![...] }).await?;\n///\n/// // Request immediate sync\n/// emit_event(DaemonEvent::ForceSync).await?;\n/// ```\npub async fn emit_event(event: DaemonEvent) -> Result<()> {\n    emit_event_with_settings(event, None).await\n}\n\n/// Emit an event to the daemon with explicit settings.\n///\n/// If settings are not provided, they will be loaded from the default location.\n/// If the daemon isn't running, this will silently succeed.\npub async fn emit_event_with_settings(\n    event: DaemonEvent,\n    settings: Option<&Settings>,\n) -> Result<()> {\n    // Load settings if not provided\n    let owned_settings;\n    let settings = match settings {\n        Some(s) => s,\n        None => {\n            owned_settings = Settings::new()?;\n            &owned_settings\n        }\n    };\n\n    // Try to connect - if daemon isn't running, that's fine\n    let mut client = match ControlClient::from_settings(settings).await {\n        Ok(c) => c,\n        Err(e) => {\n            tracing::debug!(?e, \"daemon not running, skipping event emission\");\n            return Ok(());\n        }\n    };\n\n    // Send the event\n    if let Err(e) = client.send_event(event).await {\n        tracing::debug!(?e, \"failed to send event to daemon\");\n        // Don't fail - this is fire-and-forget\n    }\n\n    Ok(())\n}\n"
  },
  {
    "path": "crates/atuin-daemon/src/components/history.rs",
    "content": "//! History component.\n//!\n//! Handles command history lifecycle (start/end) and provides the History gRPC service.\n\nuse std::sync::Arc;\n\nuse atuin_client::{\n    database::Database,\n    history::{History, HistoryId, store::HistoryStore},\n    settings::Settings,\n};\nuse dashmap::DashMap;\nuse eyre::Result;\nuse time::OffsetDateTime;\nuse tonic::{Request, Response, Status};\nuse tracing::{Level, instrument};\n\nuse crate::{\n    daemon::{Component, DaemonHandle},\n    events::DaemonEvent,\n    history::{\n        EndHistoryReply, EndHistoryRequest, ShutdownReply, ShutdownRequest, StartHistoryReply,\n        StartHistoryRequest, StatusReply, StatusRequest,\n        history_server::{History as HistorySvc, HistoryServer},\n    },\n};\n\nconst DAEMON_PROTOCOL_VERSION: u32 = 1;\n\n/// History component - manages command history lifecycle.\n///\n/// This component:\n/// - Tracks currently running commands (stored in memory)\n/// - Saves completed commands to the database and record store\n/// - Emits history events for other components (e.g., search indexing)\n/// - Provides the History gRPC service\npub struct HistoryComponent {\n    inner: Arc<HistoryComponentInner>,\n}\n\nstruct HistoryComponentInner {\n    /// Commands currently running (not yet completed).\n    running: DashMap<HistoryId, History>,\n\n    /// Handle to the daemon (set during start).\n    handle: tokio::sync::RwLock<Option<DaemonHandle>>,\n\n    /// History store for pushing records (set during start).\n    history_store: tokio::sync::RwLock<Option<HistoryStore>>,\n}\n\nimpl HistoryComponent {\n    /// Create a new history component.\n    pub fn new() -> Self {\n        Self {\n            inner: Arc::new(HistoryComponentInner {\n                running: DashMap::new(),\n                handle: tokio::sync::RwLock::new(None),\n                history_store: tokio::sync::RwLock::new(None),\n            }),\n        }\n    }\n\n    /// Get the gRPC service for this component.\n    ///\n    /// This returns a tonic service that can be added to a gRPC server.\n    pub fn grpc_service(&self) -> HistoryServer<HistoryGrpcService> {\n        HistoryServer::new(HistoryGrpcService {\n            inner: self.inner.clone(),\n        })\n    }\n}\n\nimpl Default for HistoryComponent {\n    fn default() -> Self {\n        Self::new()\n    }\n}\n\n#[tonic::async_trait]\nimpl Component for HistoryComponent {\n    fn name(&self) -> &'static str {\n        \"history\"\n    }\n\n    async fn start(&mut self, handle: DaemonHandle) -> Result<()> {\n        // Create the history store\n        let host_id = Settings::host_id().await?;\n        let history_store =\n            HistoryStore::new(handle.store().clone(), host_id, *handle.encryption_key());\n\n        *self.inner.history_store.write().await = Some(history_store);\n        *self.inner.handle.write().await = Some(handle);\n\n        tracing::info!(\"history component started\");\n        Ok(())\n    }\n\n    async fn handle_event(&mut self, _event: &DaemonEvent) -> Result<()> {\n        // History component produces events but doesn't need to react to them\n        Ok(())\n    }\n\n    async fn stop(&mut self) -> Result<()> {\n        tracing::info!(\"history component stopped\");\n        Ok(())\n    }\n}\n\n/// The gRPC service implementation.\n///\n/// This is a thin wrapper that delegates to the component's shared state.\npub struct HistoryGrpcService {\n    inner: Arc<HistoryComponentInner>,\n}\n\n#[tonic::async_trait]\nimpl HistorySvc for HistoryGrpcService {\n    #[instrument(skip_all, level = Level::INFO)]\n    async fn start_history(\n        &self,\n        request: Request<StartHistoryRequest>,\n    ) -> Result<Response<StartHistoryReply>, Status> {\n        let req = request.into_inner();\n\n        let timestamp =\n            OffsetDateTime::from_unix_timestamp_nanos(req.timestamp as i128).map_err(|_| {\n                Status::invalid_argument(\n                    \"failed to parse timestamp as unix time (expected nanos since epoch)\",\n                )\n            })?;\n\n        let h: History = History::daemon()\n            .timestamp(timestamp)\n            .command(req.command)\n            .cwd(req.cwd)\n            .session(req.session)\n            .hostname(req.hostname)\n            .build()\n            .into();\n\n        // Emit the event\n        if let Some(handle) = self.inner.handle.read().await.as_ref() {\n            handle.emit(DaemonEvent::HistoryStarted(h.clone()));\n        }\n\n        let id = h.id.clone();\n        tracing::info!(id = id.to_string(), \"start history\");\n        self.inner.running.insert(id.clone(), h);\n\n        let reply = StartHistoryReply {\n            id: id.to_string(),\n            version: env!(\"CARGO_PKG_VERSION\").to_string(),\n            protocol: DAEMON_PROTOCOL_VERSION,\n        };\n\n        Ok(Response::new(reply))\n    }\n\n    #[instrument(skip_all, level = Level::INFO)]\n    async fn end_history(\n        &self,\n        request: Request<EndHistoryRequest>,\n    ) -> Result<Response<EndHistoryReply>, Status> {\n        let req = request.into_inner();\n        let id = HistoryId(req.id);\n\n        if let Some((_, mut history)) = self.inner.running.remove(&id) {\n            history.exit = req.exit;\n            history.duration = match req.duration {\n                0 => i64::try_from(\n                    (OffsetDateTime::now_utc() - history.timestamp).whole_nanoseconds(),\n                )\n                .expect(\"failed to convert calculated duration to i64\"),\n                value => i64::try_from(value).expect(\"failed to get i64 duration\"),\n            };\n\n            // Get the handle and store to save the history\n            let handle_guard = self.inner.handle.read().await;\n            let handle = handle_guard\n                .as_ref()\n                .ok_or_else(|| Status::internal(\"component not initialized\"))?;\n\n            let store_guard = self.inner.history_store.read().await;\n            let history_store = store_guard\n                .as_ref()\n                .ok_or_else(|| Status::internal(\"component not initialized\"))?;\n\n            // Save to database\n            handle\n                .history_db()\n                .save(&history)\n                .await\n                .map_err(|e| Status::internal(format!(\"failed to write to db: {e:?}\")))?;\n\n            tracing::info!(\n                id = id.0.to_string(),\n                duration = history.duration,\n                \"end history\"\n            );\n\n            // Push to record store\n            let (record_id, idx) = history_store\n                .push(history.clone())\n                .await\n                .map_err(|e| Status::internal(format!(\"failed to push record to store: {e:?}\")))?;\n\n            // Emit the event\n            handle.emit(DaemonEvent::HistoryEnded(history));\n\n            let reply = EndHistoryReply {\n                id: record_id.0.to_string(),\n                idx,\n                version: env!(\"CARGO_PKG_VERSION\").to_string(),\n                protocol: DAEMON_PROTOCOL_VERSION,\n            };\n\n            return Ok(Response::new(reply));\n        }\n\n        Err(Status::not_found(format!(\n            \"could not find history with id: {id}\"\n        )))\n    }\n\n    #[instrument(skip_all, level = Level::INFO)]\n    async fn status(\n        &self,\n        _request: Request<StatusRequest>,\n    ) -> Result<Response<StatusReply>, Status> {\n        let reply = StatusReply {\n            healthy: true,\n            version: env!(\"CARGO_PKG_VERSION\").to_string(),\n            pid: std::process::id(),\n            protocol: DAEMON_PROTOCOL_VERSION,\n        };\n\n        Ok(Response::new(reply))\n    }\n\n    #[instrument(skip_all, level = Level::INFO)]\n    async fn shutdown(\n        &self,\n        _request: Request<ShutdownRequest>,\n    ) -> Result<Response<ShutdownReply>, Status> {\n        // Use the daemon handle to request shutdown\n        if let Some(handle) = self.inner.handle.read().await.as_ref() {\n            handle.shutdown();\n        }\n        Ok(Response::new(ShutdownReply { accepted: true }))\n    }\n}\n"
  },
  {
    "path": "crates/atuin-daemon/src/components/mod.rs",
    "content": "//! Daemon components.\n//!\n//! Components are the building blocks of the daemon. Each component handles\n//! a specific domain and can:\n//!\n//! - Expose gRPC services\n//! - React to events\n//! - Spawn background tasks\n//!\n//! Available components:\n//!\n//! - [`history::HistoryComponent`]: Command history lifecycle management\n//! - [`search::SearchComponent`]: Fuzzy search over history\n//! - [`sync::SyncComponent`]: Cloud sync\n\npub mod history;\npub mod search;\npub mod sync;\n\npub use history::HistoryComponent;\npub use search::SearchComponent;\npub use sync::SyncComponent;\n"
  },
  {
    "path": "crates/atuin-daemon/src/components/search.rs",
    "content": "//! Search component.\n//!\n//! Provides fuzzy search over command history using the Nucleo search library\n//! with frecency-based ranking and dynamic filtering.\n\nuse std::{pin::Pin, sync::Arc};\n\nuse atuin_client::database::Database;\nuse eyre::Result;\nuse tokio::sync::RwLock;\nuse tokio_stream::Stream;\nuse tonic::{Request, Response, Status, Streaming};\nuse tracing::{Level, debug, info, instrument, span, trace};\nuse uuid::Uuid;\n\nuse crate::{\n    daemon::{Component, DaemonHandle},\n    events::DaemonEvent,\n    search::{\n        FilterMode, IndexFilterMode, QueryContext, SearchIndex, SearchRequest, SearchResponse,\n        search_server::{Search as SearchSvc, SearchServer},\n    },\n};\n\nconst PAGE_SIZE: usize = 5000;\nconst RESULTS_LIMIT: u32 = 200;\n/// How often to rebuild the frecency map (in seconds).\nconst FRECENCY_REFRESH_INTERVAL_SECS: u64 = 60;\n\n/// Search component - provides fuzzy search over command history.\n///\n/// This component:\n/// - Maintains a deduplicated search index with frecency ranking\n/// - Loads history from the database on startup\n/// - Updates the index when history events occur\n/// - Provides the Search gRPC service\npub struct SearchComponent {\n    index: Arc<RwLock<SearchIndex>>,\n    handle: tokio::sync::RwLock<Option<DaemonHandle>>,\n    loader_handle: Option<tokio::task::JoinHandle<()>>,\n    frecency_handle: Option<tokio::task::JoinHandle<()>>,\n}\n\nimpl SearchComponent {\n    /// Create a new search component.\n    pub fn new() -> Self {\n        Self {\n            index: Arc::new(RwLock::new(SearchIndex::new())),\n            handle: tokio::sync::RwLock::new(None),\n            loader_handle: None,\n            frecency_handle: None,\n        }\n    }\n\n    /// Get the gRPC service for this component.\n    pub fn grpc_service(&self) -> SearchServer<SearchGrpcService> {\n        SearchServer::new(SearchGrpcService {\n            index: self.index.clone(),\n        })\n    }\n\n    /// Rebuild the entire search index from the database.\n    async fn rebuild_index(&self) -> Result<()> {\n        let handle_guard = self.handle.read().await;\n        let handle = handle_guard\n            .as_ref()\n            .ok_or_else(|| eyre::eyre!(\"component not initialized\"))?;\n\n        info!(\"Rebuilding search index from database\");\n\n        // Create a new index\n        let new_index = SearchIndex::new();\n\n        // Load all history into the new index\n        let db = handle.history_db().clone();\n        let mut pager = db.all_paged(PAGE_SIZE, false, true);\n        loop {\n            match pager.next().await {\n                Ok(Some(histories)) => {\n                    info!(\n                        \"Loading {} history entries into search index\",\n                        histories.len()\n                    );\n                    new_index.add_histories(&histories);\n                }\n                Ok(None) => break,\n                Err(e) => {\n                    tracing::error!(\"Failed to load history during rebuild: {}\", e);\n                    break;\n                }\n            }\n        }\n\n        info!(\n            \"Search index rebuild complete; {} unique commands\",\n            new_index.command_count()\n        );\n\n        // Replace the old index with the new one\n        *self.index.write().await = new_index;\n        Ok(())\n    }\n}\n\nimpl Default for SearchComponent {\n    fn default() -> Self {\n        Self::new()\n    }\n}\n\n#[tonic::async_trait]\nimpl Component for SearchComponent {\n    fn name(&self) -> &'static str {\n        \"search\"\n    }\n\n    async fn start(&mut self, handle: DaemonHandle) -> Result<()> {\n        *self.handle.write().await = Some(handle.clone());\n\n        // Spawn background task to load history into index\n        let index = self.index.clone();\n        let db = handle.history_db().clone();\n        let handle_for_loader = handle.clone();\n\n        self.loader_handle = Some(tokio::spawn(async move {\n            info!(\n                \"Loading history into search index; page size = {}\",\n                PAGE_SIZE\n            );\n            let mut pager = db.all_paged(PAGE_SIZE, false, true);\n            loop {\n                match pager.next().await {\n                    Ok(Some(histories)) => {\n                        info!(\n                            \"Loading {} history entries into search index\",\n                            histories.len()\n                        );\n                        index.read().await.add_histories(&histories);\n                    }\n                    Ok(None) => {\n                        info!(\n                            \"Initial history load complete; {} unique commands indexed\",\n                            index.read().await.command_count()\n                        );\n                        // Build initial frecency map with current settings\n                        let settings = handle_for_loader.settings().await;\n                        index.read().await.rebuild_frecency(&settings.search).await;\n                        info!(\"Initial frecency map built\");\n                        break;\n                    }\n                    Err(e) => {\n                        tracing::error!(\"Failed to load history: {}\", e);\n                        break;\n                    }\n                }\n            }\n        }));\n\n        // Spawn background task to periodically refresh frecency\n        let index_for_frecency = self.index.clone();\n        let handle_for_frecency = handle.clone();\n        self.frecency_handle = Some(tokio::spawn(async move {\n            let mut interval = tokio::time::interval(std::time::Duration::from_secs(\n                FRECENCY_REFRESH_INTERVAL_SECS,\n            ));\n            loop {\n                interval.tick().await;\n                trace!(\"Refreshing frecency map\");\n                let settings = handle_for_frecency.settings().await;\n                index_for_frecency\n                    .read()\n                    .await\n                    .rebuild_frecency(&settings.search)\n                    .await;\n            }\n        }));\n\n        tracing::info!(\"search component started\");\n        Ok(())\n    }\n\n    async fn handle_event(&mut self, event: &DaemonEvent) -> Result<()> {\n        match event {\n            DaemonEvent::RecordsAdded(records) => {\n                debug!(\n                    count = records.len(),\n                    \"Processing added records for search index\"\n                );\n\n                let handle_guard = self.handle.read().await;\n                if let Some(handle) = handle_guard.as_ref() {\n                    let histories: Vec<_> = handle\n                        .history_db()\n                        .query_history(\n                            format!(\n                                \"select * from history where id in ({})\",\n                                records\n                                    .iter()\n                                    .map(|record| record.0.to_string())\n                                    .collect::<Vec<_>>()\n                                    .join(\",\")\n                            )\n                            .as_str(),\n                        )\n                        .await\n                        .unwrap_or_default();\n\n                    span!(Level::TRACE, \"inject_records\", count = histories.len())\n                        .in_scope(async || {\n                            self.index.read().await.add_histories(&histories);\n                        })\n                        .await;\n                }\n            }\n            DaemonEvent::HistoryStarted(history) => {\n                debug!(id = %history.id, command = %history.command, \"History started (no index action)\");\n            }\n            DaemonEvent::HistoryEnded(history) => {\n                span!(Level::TRACE, \"inject_history_ended\")\n                    .in_scope(async || {\n                        self.index.read().await.add_history(history);\n                    })\n                    .await;\n            }\n            DaemonEvent::HistoryPruned | DaemonEvent::HistoryRebuilt => {\n                info!(\"History store pruned or rebuilt, rebuilding search index\");\n                if let Err(e) = self.rebuild_index().await {\n                    tracing::error!(\"Failed to rebuild search index: {}\", e);\n                }\n            }\n            DaemonEvent::HistoryDeleted { ids } => {\n                info!(\n                    count = ids.len(),\n                    \"History deleted, rebuilding search index\"\n                );\n                // For now, just rebuild the entire index. A more efficient implementation\n                // would remove specific items from the index.\n                if let Err(e) = self.rebuild_index().await {\n                    tracing::error!(\"Failed to rebuild search index: {}\", e);\n                }\n            }\n            DaemonEvent::SettingsReloaded => {\n                info!(\"Settings reloaded, rebuilding frecency map with new multipliers\");\n                let handle_guard = self.handle.read().await;\n                if let Some(handle) = handle_guard.as_ref() {\n                    let settings = handle.settings().await;\n                    self.index\n                        .read()\n                        .await\n                        .rebuild_frecency(&settings.search)\n                        .await;\n                }\n            }\n            // Events we don't care about\n            DaemonEvent::SyncCompleted { .. }\n            | DaemonEvent::SyncFailed { .. }\n            | DaemonEvent::ForceSync\n            | DaemonEvent::ShutdownRequested => {}\n        }\n        Ok(())\n    }\n\n    async fn stop(&mut self) -> Result<()> {\n        if let Some(handle) = self.loader_handle.take() {\n            handle.abort();\n        }\n        if let Some(handle) = self.frecency_handle.take() {\n            handle.abort();\n        }\n        tracing::info!(\"search component stopped\");\n        Ok(())\n    }\n}\n\n/// The gRPC service implementation.\npub struct SearchGrpcService {\n    index: Arc<RwLock<SearchIndex>>,\n}\n\n#[tonic::async_trait]\nimpl SearchSvc for SearchGrpcService {\n    type SearchStream = Pin<Box<dyn Stream<Item = Result<SearchResponse, Status>> + Send>>;\n\n    #[instrument(skip_all, level = Level::TRACE, name = \"search_rpc\")]\n    async fn search(\n        &self,\n        request: Request<Streaming<SearchRequest>>,\n    ) -> Result<Response<Self::SearchStream>, Status> {\n        let mut in_stream = request.into_inner();\n        let index = self.index.clone();\n\n        // Create output channel\n        let (tx, rx) = tokio::sync::mpsc::channel::<Result<SearchResponse, Status>>(128);\n\n        // Spawn task to handle incoming requests and send responses\n        tokio::spawn(async move {\n            while let Some(req) = in_stream.message().await.transpose() {\n                match req {\n                    Ok(search_req) => {\n                        let query = search_req.query;\n                        let query_id = search_req.query_id;\n                        let filter_mode: FilterMode = search_req\n                            .filter_mode\n                            .try_into()\n                            .unwrap_or(FilterMode::Global);\n                        let proto_context = search_req.context;\n\n                        debug!(\n                            \"search request: query = {}, query_id = {}, filter_mode = {}, context = {:?}\",\n                            query,\n                            query_id,\n                            filter_mode.as_str_name(),\n                            proto_context\n                        );\n\n                        // Convert proto FilterMode + context to IndexFilterMode\n                        let index_filter = convert_filter_mode(filter_mode, &proto_context);\n\n                        // Build QueryContext from proto context\n                        let query_context = proto_context\n                            .map(|ctx| QueryContext {\n                                cwd: Some(with_trailing_slash(&ctx.cwd)),\n                                git_root: ctx.git_root.map(|s| with_trailing_slash(&s)),\n                                hostname: Some(ctx.hostname),\n                                session_id: Some(ctx.session_id),\n                            })\n                            .unwrap_or_default();\n\n                        // Perform the search\n                        let history_ids =\n                            span!(Level::TRACE, \"daemon_search_query\", %query, query_id)\n                                .in_scope(|| async {\n                                    let index = index.read().await;\n                                    index\n                                        .search(&query, index_filter, &query_context, RESULTS_LIMIT)\n                                        .await\n                                })\n                                .await;\n\n                        // Convert history IDs to bytes\n                        let ids: Vec<Vec<u8>> = history_ids\n                            .iter()\n                            .filter_map(|id| {\n                                Uuid::parse_str(id)\n                                    .ok()\n                                    .map(|uuid| uuid.as_bytes().to_vec())\n                            })\n                            .collect();\n\n                        if tx.send(Ok(SearchResponse { query_id, ids })).await.is_err() {\n                            break; // Client disconnected\n                        }\n                    }\n                    Err(e) => {\n                        let _ = tx.send(Err(e)).await;\n                        break;\n                    }\n                }\n            }\n        });\n\n        // Convert receiver to stream\n        let out_stream = tokio_stream::wrappers::ReceiverStream::new(rx);\n        Ok(Response::new(Box::pin(out_stream)))\n    }\n}\n\n/// Convert proto FilterMode and context to IndexFilterMode.\nfn convert_filter_mode(\n    mode: FilterMode,\n    context: &Option<crate::search::SearchContext>,\n) -> IndexFilterMode {\n    match (mode, context) {\n        (FilterMode::Global, _) => IndexFilterMode::Global,\n        (FilterMode::Directory, Some(ctx)) => {\n            IndexFilterMode::Directory(with_trailing_slash(&ctx.cwd))\n        }\n        (FilterMode::Workspace, Some(ctx)) => {\n            if let Some(ref git_root) = ctx.git_root {\n                IndexFilterMode::Workspace(with_trailing_slash(git_root))\n            } else {\n                // Fall back to directory if no git root\n                IndexFilterMode::Directory(with_trailing_slash(&ctx.cwd))\n            }\n        }\n        (FilterMode::Host, Some(ctx)) => IndexFilterMode::Host(ctx.hostname.clone()),\n        (FilterMode::Session, Some(ctx)) => IndexFilterMode::Session(ctx.session_id.clone()),\n        (FilterMode::SessionPreload, Some(ctx)) => {\n            // SessionPreload is similar to Session - filter by session\n            IndexFilterMode::Session(ctx.session_id.clone())\n        }\n        // If no context provided, fall back to global\n        _ => IndexFilterMode::Global,\n    }\n}\n\n#[cfg(windows)]\npub fn with_trailing_slash(s: &str) -> String {\n    if s.ends_with('\\\\') {\n        s.to_string()\n    } else {\n        format!(\"{}\\\\\", s)\n    }\n}\n\n#[cfg(not(windows))]\npub fn with_trailing_slash(s: &str) -> String {\n    if s.ends_with('/') {\n        s.to_string()\n    } else {\n        format!(\"{}/\", s)\n    }\n}\n"
  },
  {
    "path": "crates/atuin-daemon/src/components/sync.rs",
    "content": "//! Sync component.\n//!\n//! Handles periodic synchronization with the Atuin cloud server.\n\nuse std::time::Duration;\n\nuse eyre::Result;\nuse rand::Rng;\nuse tokio::sync::mpsc;\nuse tokio::time::{self, MissedTickBehavior};\n\nuse atuin_client::{history::store::HistoryStore, record::sync, settings::Settings};\nuse atuin_dotfiles::store::{AliasStore, var::VarStore};\n\nuse crate::{\n    daemon::{Component, DaemonHandle},\n    events::DaemonEvent,\n};\n\n/// Commands that can be sent to the sync task.\nenum SyncCommand {\n    /// Trigger an immediate sync.\n    ForceSync,\n    /// Stop the sync loop.\n    Stop,\n}\n\n/// Sync state - tracks whether we're in normal operation or retrying after failure.\n#[derive(Clone, Copy, PartialEq, Eq)]\nenum SyncState {\n    /// Normal operation. Periodic syncs only run if auto_sync is enabled.\n    Idle,\n    /// Retrying after a sync failure. Retries continue regardless of auto_sync\n    /// until the sync succeeds.\n    Retrying,\n}\n\n/// Sync component - handles periodic cloud synchronization.\n///\n/// This component:\n/// - Runs a background sync loop on a configurable interval\n/// - Implements exponential backoff on sync failures\n/// - Responds to ForceSync events for immediate sync\n/// - Emits SyncCompleted/SyncFailed events\npub struct SyncComponent {\n    task_handle: Option<tokio::task::JoinHandle<()>>,\n    command_tx: Option<mpsc::Sender<SyncCommand>>,\n}\n\nimpl SyncComponent {\n    /// Create a new sync component.\n    pub fn new() -> Self {\n        Self {\n            task_handle: None,\n            command_tx: None,\n        }\n    }\n}\n\nimpl Default for SyncComponent {\n    fn default() -> Self {\n        Self::new()\n    }\n}\n\n#[tonic::async_trait]\nimpl Component for SyncComponent {\n    fn name(&self) -> &'static str {\n        \"sync\"\n    }\n\n    async fn start(&mut self, handle: DaemonHandle) -> Result<()> {\n        let (cmd_tx, cmd_rx) = mpsc::channel(16);\n        self.command_tx = Some(cmd_tx);\n\n        // Spawn the sync loop with its own copy of the handle\n        self.task_handle = Some(tokio::spawn(sync_loop(handle, cmd_rx)));\n\n        tracing::info!(\"sync component started\");\n        Ok(())\n    }\n\n    async fn handle_event(&mut self, event: &DaemonEvent) -> Result<()> {\n        if let DaemonEvent::ForceSync = event {\n            tracing::info!(\"force sync requested\");\n            if let Some(tx) = &self.command_tx {\n                let _ = tx.send(SyncCommand::ForceSync).await;\n            }\n        }\n        Ok(())\n    }\n\n    async fn stop(&mut self) -> Result<()> {\n        if let Some(tx) = &self.command_tx {\n            let _ = tx.send(SyncCommand::Stop).await;\n        }\n        if let Some(handle) = self.task_handle.take() {\n            // Give the task a moment to shut down gracefully\n            let _ = tokio::time::timeout(std::time::Duration::from_secs(5), handle).await;\n        }\n        tracing::info!(\"sync component stopped\");\n        Ok(())\n    }\n}\n\n/// The main sync loop.\n///\n/// This runs in a spawned task and handles periodic sync as well as\n/// force sync requests.\nasync fn sync_loop(handle: DaemonHandle, mut cmd_rx: mpsc::Receiver<SyncCommand>) {\n    tracing::info!(\"sync loop starting\");\n\n    // Clone settings since we need them across await points\n    let settings = handle.settings().await.clone();\n    let host_id = match Settings::host_id().await {\n        Ok(id) => id,\n        Err(e) => {\n            tracing::error!(\"failed to get host id, sync disabled: {e}\");\n            return;\n        }\n    };\n\n    // Create the stores we need\n    let encryption_key = *handle.encryption_key();\n    let history_store = HistoryStore::new(handle.store().clone(), host_id, encryption_key);\n    let alias_store = AliasStore::new(handle.store().clone(), host_id, encryption_key);\n    let var_store = VarStore::new(handle.store().clone(), host_id, encryption_key);\n\n    // Don't backoff by more than 30 mins (with a random jitter of up to 1 min)\n    let max_interval: f64 = 60.0 * 30.0 + rand::thread_rng().gen_range(0.0..60.0);\n\n    let mut ticker = time::interval(time::Duration::from_secs(settings.daemon.sync_frequency));\n\n    // IMPORTANT: without this, if we miss ticks because a sync takes ages or is otherwise delayed,\n    // we may end up running a lot of syncs in a hot loop.\n    ticker.set_missed_tick_behavior(MissedTickBehavior::Skip);\n\n    let mut sync_state = SyncState::Idle;\n\n    loop {\n        tokio::select! {\n            _ = ticker.tick() => {\n                let settings = handle.settings().await;\n\n                // Skip periodic ticks if auto_sync is disabled AND we're not retrying\n                // a previous failure. Retries must continue regardless of auto_sync.\n                if !settings.auto_sync && sync_state == SyncState::Idle {\n                    tracing::debug!(\"auto_sync disabled, skipping periodic sync tick\");\n                    continue;\n                }\n\n                sync_state = do_sync_tick(\n                    &handle,\n                    &history_store,\n                    &alias_store,\n                    &var_store,\n                    &mut ticker,\n                    max_interval,\n                    &settings,\n                ).await;\n            }\n            cmd = cmd_rx.recv() => {\n                match cmd {\n                    Some(SyncCommand::ForceSync) => {\n                        tracing::info!(\"executing force sync\");\n                        let settings = handle.settings().await;\n                        sync_state = do_sync_tick(\n                            &handle,\n                            &history_store,\n                            &alias_store,\n                            &var_store,\n                            &mut ticker,\n                            max_interval,\n                            &settings,\n                        ).await;\n                    }\n                    Some(SyncCommand::Stop) | None => {\n                        tracing::info!(\"sync loop stopping\");\n                        break;\n                    }\n                }\n            }\n        }\n    }\n}\n\n/// Execute a single sync tick.\n///\n/// Returns the new sync state: `Idle` on success, `Retrying` on failure.\nasync fn do_sync_tick(\n    handle: &DaemonHandle,\n    history_store: &HistoryStore,\n    alias_store: &AliasStore,\n    var_store: &VarStore,\n    ticker: &mut time::Interval,\n    max_interval: f64,\n    settings: &Settings,\n) -> SyncState {\n    tracing::info!(\"sync tick\");\n\n    // Check if logged in\n    let logged_in = match settings.logged_in().await {\n        Ok(v) => v,\n        Err(e) => {\n            tracing::warn!(\"failed to check login status, skipping sync tick: {e}\");\n            return SyncState::Idle;\n        }\n    };\n\n    if !logged_in {\n        tracing::debug!(\"not logged in, skipping sync tick\");\n        return SyncState::Idle;\n    }\n\n    // Perform the sync\n    let res = sync::sync(settings, handle.store()).await;\n\n    match res {\n        Err(e) => {\n            tracing::error!(\"sync tick failed with {e}\");\n\n            // Emit failure event\n            handle.emit(DaemonEvent::SyncFailed {\n                error: e.to_string(),\n            });\n\n            // Exponential backoff\n            let mut rng = rand::thread_rng();\n            let mut new_interval = ticker.period().as_secs_f64() * rng.gen_range(2.0..2.2);\n\n            if new_interval > max_interval {\n                new_interval = max_interval;\n            }\n\n            *ticker = time::interval_at(\n                tokio::time::Instant::now() + Duration::from_secs(new_interval as u64),\n                time::Duration::from_secs(new_interval as u64),\n            );\n            ticker.reset_after(time::Duration::from_secs(new_interval as u64));\n            ticker.set_missed_tick_behavior(MissedTickBehavior::Skip);\n\n            tracing::error!(\"backing off, next sync tick in {new_interval}\");\n\n            SyncState::Retrying\n        }\n        Ok((uploaded_count, downloaded_records)) => {\n            tracing::info!(\n                uploaded = uploaded_count,\n                downloaded = downloaded_records.len(),\n                \"sync complete\"\n            );\n\n            // Build history from downloaded records\n            if let Err(e) = history_store\n                .incremental_build(handle.history_db(), &downloaded_records)\n                .await\n            {\n                tracing::error!(\"failed to build history from downloaded records: {e}\");\n            }\n\n            // Emit the records added event (for search indexing)\n            handle.emit(DaemonEvent::RecordsAdded(downloaded_records.clone()));\n\n            // Emit sync completed event\n            handle.emit(DaemonEvent::SyncCompleted {\n                uploaded: uploaded_count as usize,\n                downloaded: downloaded_records.len(),\n            });\n\n            // Rebuild alias and var stores\n            if let Err(e) = alias_store.build().await {\n                tracing::error!(\"failed to rebuild alias store: {e}\");\n            }\n            if let Err(e) = var_store.build().await {\n                tracing::error!(\"failed to rebuild var store: {e}\");\n            }\n\n            // Reset backoff on success\n            if ticker.period().as_secs() != settings.daemon.sync_frequency {\n                *ticker = time::interval_at(\n                    tokio::time::Instant::now()\n                        + Duration::from_secs(settings.daemon.sync_frequency),\n                    time::Duration::from_secs(settings.daemon.sync_frequency),\n                );\n                ticker.set_missed_tick_behavior(MissedTickBehavior::Skip);\n            }\n\n            // Store sync time\n            if let Err(e) = Settings::save_sync_time().await {\n                tracing::error!(\"failed to save sync time: {e}\");\n            }\n\n            SyncState::Idle\n        }\n    }\n}\n"
  },
  {
    "path": "crates/atuin-daemon/src/control/mod.rs",
    "content": "//! Control module for external event injection.\n//!\n//! This module provides the gRPC service that allows external processes\n//! (like CLI commands) to inject events into the daemon's event bus.\n\nmod service;\n\n// Include the generated proto code\ntonic::include_proto!(\"control\");\n\n// Re-export the service\npub use service::ControlService;\n"
  },
  {
    "path": "crates/atuin-daemon/src/control/service.rs",
    "content": "//! Control service implementation.\n//!\n//! This gRPC service allows external processes (like CLI commands) to inject\n//! events into the daemon's event bus.\n\nuse atuin_client::history::HistoryId;\nuse tonic::{Request, Response, Status};\nuse tracing::{Level, info, instrument};\n\nuse super::{\n    SendEventRequest, SendEventResponse,\n    control_server::{Control, ControlServer},\n    send_event_request::Event,\n};\nuse crate::{daemon::DaemonHandle, events::DaemonEvent};\n\n/// The Control gRPC service.\n///\n/// This service is used by external processes to inject events into the daemon.\n/// It's not a component - it's part of the daemon's core infrastructure.\npub struct ControlService {\n    handle: DaemonHandle,\n}\n\nimpl ControlService {\n    /// Create a new control service with the given daemon handle.\n    pub fn new(handle: DaemonHandle) -> Self {\n        Self { handle }\n    }\n\n    /// Get a tonic server for this service.\n    pub fn into_server(self) -> ControlServer<Self> {\n        ControlServer::new(self)\n    }\n}\n\n#[tonic::async_trait]\nimpl Control for ControlService {\n    #[instrument(skip_all, level = Level::INFO, name = \"control_send_event\")]\n    async fn send_event(\n        &self,\n        request: Request<SendEventRequest>,\n    ) -> Result<Response<SendEventResponse>, Status> {\n        let req = request.into_inner();\n\n        let event = req\n            .event\n            .ok_or_else(|| Status::invalid_argument(\"event is required\"))?;\n\n        let daemon_event = proto_event_to_daemon_event(event)?;\n\n        info!(?daemon_event, \"received control event\");\n        self.handle.emit(daemon_event);\n\n        Ok(Response::new(SendEventResponse {}))\n    }\n}\n\n/// Convert a proto event to a daemon event.\nfn proto_event_to_daemon_event(event: Event) -> Result<DaemonEvent, Status> {\n    match event {\n        Event::HistoryPruned(_) => Ok(DaemonEvent::HistoryPruned),\n        Event::HistoryRebuilt(_) => Ok(DaemonEvent::HistoryRebuilt),\n        Event::HistoryDeleted(e) => Ok(DaemonEvent::HistoryDeleted {\n            ids: e.ids.into_iter().map(HistoryId).collect(),\n        }),\n        Event::ForceSync(_) => Ok(DaemonEvent::ForceSync),\n        Event::SettingsReloaded(_) => Ok(DaemonEvent::SettingsReloaded),\n        Event::Shutdown(_) => Ok(DaemonEvent::ShutdownRequested),\n    }\n}\n"
  },
  {
    "path": "crates/atuin-daemon/src/daemon.rs",
    "content": "//! Core daemon infrastructure.\n//!\n//! This module provides the foundational types for building the atuin daemon:\n//!\n//! - [`DaemonState`]: Shared state owned by the daemon\n//! - [`DaemonHandle`]: A lightweight, cloneable handle for accessing daemon state\n//! - [`Component`]: A trait for implementing daemon components\n//! - [`Daemon`]: The main daemon orchestrator\n//! - [`DaemonBuilder`]: Builder for constructing and configuring the daemon\n\nuse std::sync::Arc;\n\nuse atuin_client::{\n    database::Sqlite as HistoryDatabase, encryption, record::sqlite_store::SqliteStore,\n    settings::Settings,\n};\nuse eyre::{Context, Result};\nuse tokio::sync::{RwLock, broadcast};\n\nuse crate::events::DaemonEvent;\n\n// ============================================================================\n// DaemonState\n// ============================================================================\n\n/// Shared state owned by the daemon.\n///\n/// This contains all the resources that components and services need access to.\n/// The state is wrapped in an `Arc` and accessed via [`DaemonHandle`].\npub struct DaemonState {\n    // Event bus\n    event_tx: broadcast::Sender<DaemonEvent>,\n\n    // Configuration (mutable - can be reloaded)\n    settings: RwLock<Settings>,\n\n    // Encryption key (immutable - derived at startup)\n    encryption_key: [u8; 32],\n\n    // Database handles\n    history_db: HistoryDatabase,\n    store: SqliteStore,\n}\n\n// ============================================================================\n// DaemonHandle\n// ============================================================================\n\n/// A lightweight handle to the daemon's shared state.\n///\n/// This is the primary way for components, gRPC services, and spawned tasks to\n/// interact with the daemon. It provides access to:\n///\n/// - Event emission and subscription\n/// - Configuration (settings, encryption key)\n/// - Database handles\n///\n/// The handle is cheaply cloneable (wraps an `Arc`) and can be freely passed\n/// around to any code that needs daemon access.\n///\n/// # Example\n///\n/// ```ignore\n/// // Emit an event\n/// handle.emit(DaemonEvent::HistoryPruned);\n///\n/// // Access settings\n/// let settings = handle.settings().await;\n/// let sync_freq = settings.daemon.sync_frequency;\n///\n/// // Access database\n/// let history = handle.history_db().load(id).await?;\n/// ```\n#[derive(Clone)]\npub struct DaemonHandle {\n    state: Arc<DaemonState>,\n}\n\nimpl DaemonHandle {\n    // ---- Events ----\n\n    /// Emit an event to the daemon's event bus.\n    ///\n    /// This is fire-and-forget - if no receivers are listening (which shouldn't\n    /// happen in normal operation), the event is dropped silently.\n    pub fn emit(&self, event: DaemonEvent) {\n        if let Err(e) = self.state.event_tx.send(event) {\n            tracing::warn!(\"failed to emit event (no receivers?): {e}\");\n        }\n    }\n\n    /// Subscribe to the event bus.\n    ///\n    /// Returns a receiver that will receive all events emitted after this call.\n    /// Useful for components that need to listen for events outside of the\n    /// normal `handle_event` callback flow.\n    pub fn subscribe(&self) -> broadcast::Receiver<DaemonEvent> {\n        self.state.event_tx.subscribe()\n    }\n\n    /// Request graceful shutdown of the daemon.\n    pub fn shutdown(&self) {\n        self.emit(DaemonEvent::ShutdownRequested);\n    }\n\n    // ---- Configuration ----\n\n    /// Get the current settings.\n    ///\n    /// This acquires a read lock on the settings. For most use cases, clone\n    /// the settings if you need to hold onto them.\n    pub async fn settings(&self) -> tokio::sync::RwLockReadGuard<'_, Settings> {\n        self.state.settings.read().await\n    }\n\n    /// Reload settings from disk and emit a SettingsReloaded event.\n    ///\n    /// Components listening for `SettingsReloaded` can then re-read settings\n    /// via `handle.settings()` to pick up the changes.\n    pub async fn reload_settings(&self) -> Result<()> {\n        let new_settings = Settings::new()?;\n        self.apply_settings(new_settings).await;\n        Ok(())\n    }\n\n    /// Apply already-loaded settings and emit a SettingsReloaded event.\n    ///\n    /// Use this when settings have already been loaded (e.g., from a file watcher)\n    /// to avoid parsing the config file twice.\n    pub async fn apply_settings(&self, settings: Settings) {\n        *self.state.settings.write().await = settings;\n        self.emit(DaemonEvent::SettingsReloaded);\n        tracing::info!(\"settings applied\");\n    }\n\n    /// Get the encryption key.\n    pub fn encryption_key(&self) -> &[u8; 32] {\n        &self.state.encryption_key\n    }\n\n    // ---- Database ----\n\n    /// Get a reference to the history database.\n    pub fn history_db(&self) -> &HistoryDatabase {\n        &self.state.history_db\n    }\n\n    /// Get a reference to the record store.\n    pub fn store(&self) -> &SqliteStore {\n        &self.state.store\n    }\n}\n\nimpl std::fmt::Debug for DaemonHandle {\n    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n        f.debug_struct(\"DaemonHandle\").finish_non_exhaustive()\n    }\n}\n\n// ============================================================================\n// Component Trait\n// ============================================================================\n\n/// A daemon component that handles a specific domain.\n///\n/// Components are the building blocks of the daemon. Each component:\n///\n/// - Has a unique name for logging and debugging\n/// - Can optionally expose gRPC services\n/// - Receives a [`DaemonHandle`] on startup for accessing daemon resources\n/// - Handles events from the event bus\n/// - Performs cleanup on shutdown\n///\n/// # Lifecycle\n///\n/// 1. **Construction**: Component is created (usually via `new()`)\n/// 2. **Start**: `start()` is called with a [`DaemonHandle`]\n/// 3. **Running**: `handle_event()` is called for each event on the bus\n/// 4. **Shutdown**: `stop()` is called for cleanup\n///\n/// # Example\n///\n/// ```ignore\n/// pub struct MyComponent {\n///     handle: Option<DaemonHandle>,\n/// }\n///\n/// #[async_trait]\n/// impl Component for MyComponent {\n///     fn name(&self) -> &'static str { \"my-component\" }\n///\n///     async fn start(&mut self, handle: DaemonHandle) -> Result<()> {\n///         self.handle = Some(handle);\n///         Ok(())\n///     }\n///\n///     async fn handle_event(&mut self, event: &DaemonEvent) -> Result<()> {\n///         match event {\n///             DaemonEvent::SomeEvent => {\n///                 // Handle the event\n///                 if let Some(handle) = &self.handle {\n///                     handle.emit(DaemonEvent::ResponseEvent);\n///                 }\n///             }\n///             _ => {}\n///         }\n///         Ok(())\n///     }\n///\n///     async fn stop(&mut self) -> Result<()> {\n///         Ok(())\n///     }\n/// }\n/// ```\n#[tonic::async_trait]\npub trait Component: Send + Sync {\n    /// Human-readable name for logging and debugging.\n    fn name(&self) -> &'static str;\n\n    /// Called once at startup.\n    ///\n    /// Store the handle if you need to emit events or access daemon resources\n    /// later. The handle is cheaply cloneable, so feel free to clone it for\n    /// spawned tasks.\n    async fn start(&mut self, handle: DaemonHandle) -> Result<()>;\n\n    /// Handle an incoming event.\n    ///\n    /// Called for every event on the bus. To emit new events in response,\n    /// use the handle stored during `start()`. Events emitted here will be\n    /// processed in subsequent event loop iterations.\n    async fn handle_event(&mut self, event: &DaemonEvent) -> Result<()>;\n\n    /// Called on graceful shutdown.\n    ///\n    /// Use this to clean up resources, abort spawned tasks, etc.\n    async fn stop(&mut self) -> Result<()>;\n}\n\n// ============================================================================\n// Daemon\n// ============================================================================\n\n/// The main daemon orchestrator.\n///\n/// The daemon manages components, runs the event loop, and coordinates startup\n/// and shutdown. It is constructed via [`DaemonBuilder`].\n///\n/// # Event Loop\n///\n/// The daemon runs a simple event loop:\n///\n/// 1. Wait for an event on the bus\n/// 2. Dispatch the event to all components (in registration order)\n/// 3. Components may emit new events in response\n/// 4. Repeat until `ShutdownRequested` is received\n///\n/// Events emitted during handling are queued and processed in subsequent\n/// iterations, ensuring the loop eventually drains.\npub struct Daemon {\n    components: Vec<Box<dyn Component>>,\n    handle: DaemonHandle,\n}\n\nimpl Daemon {\n    /// Create a new daemon builder.\n    pub fn builder(settings: Settings) -> DaemonBuilder {\n        DaemonBuilder::new(settings)\n    }\n\n    /// Get a clone of the daemon handle.\n    ///\n    /// The handle can be used to emit events, access settings, etc.\n    pub fn handle(&self) -> DaemonHandle {\n        self.handle.clone()\n    }\n\n    /// Start all components.\n    ///\n    /// This must be called before `run_event_loop()`. It initializes all\n    /// registered components with the daemon handle.\n    pub async fn start_components(&mut self) -> Result<()> {\n        for component in &mut self.components {\n            tracing::info!(component = component.name(), \"starting component\");\n            component\n                .start(self.handle.clone())\n                .await\n                .with_context(|| format!(\"failed to start component: {}\", component.name()))?;\n        }\n        Ok(())\n    }\n\n    /// Run the daemon event loop.\n    ///\n    /// This processes events until a ShutdownRequested event is received.\n    /// Components must be started first via `start_components()`.\n    pub async fn run_event_loop(&mut self) -> Result<()> {\n        let mut event_rx = self.handle.subscribe();\n        loop {\n            match event_rx.recv().await {\n                Ok(DaemonEvent::ShutdownRequested) => {\n                    tracing::info!(\"shutdown requested, stopping daemon\");\n                    break;\n                }\n                Ok(event) => {\n                    tracing::debug!(?event, \"processing event\");\n                    self.dispatch_event(&event).await;\n                }\n                Err(broadcast::error::RecvError::Lagged(n)) => {\n                    tracing::warn!(\n                        skipped = n,\n                        \"event receiver lagged, some events were dropped\"\n                    );\n                }\n                Err(broadcast::error::RecvError::Closed) => {\n                    tracing::info!(\"event bus closed, stopping daemon\");\n                    break;\n                }\n            }\n        }\n        Ok(())\n    }\n\n    /// Stop all components.\n    ///\n    /// This performs graceful shutdown of all components.\n    pub async fn stop_components(&mut self) {\n        for component in &mut self.components {\n            tracing::info!(component = component.name(), \"stopping component\");\n            if let Err(e) = component.stop().await {\n                tracing::error!(\n                    component = component.name(),\n                    error = ?e,\n                    \"error stopping component\"\n                );\n            }\n        }\n        tracing::info!(\"all components stopped\");\n    }\n\n    /// Run the daemon.\n    ///\n    /// This is a convenience method that starts components, runs the event loop,\n    /// and handles shutdown. It does not return until the daemon is shut down.\n    pub async fn run(mut self) -> Result<()> {\n        self.start_components().await?;\n        self.run_event_loop().await?;\n        self.stop_components().await;\n        tracing::info!(\"daemon stopped\");\n        Ok(())\n    }\n\n    async fn dispatch_event(&mut self, event: &DaemonEvent) {\n        for component in &mut self.components {\n            if let Err(e) = component.handle_event(event).await {\n                tracing::error!(\n                    component = component.name(),\n                    error = ?e,\n                    \"error handling event\"\n                );\n            }\n        }\n    }\n}\n\n// ============================================================================\n// DaemonBuilder\n// ============================================================================\n\n/// Builder for constructing a [`Daemon`].\n///\n/// # Example\n///\n/// ```ignore\n/// let daemon = Daemon::builder(settings)\n///     .store(store)\n///     .history_db(history_db)\n///     .component(HistoryComponent::new())\n///     .component(SearchComponent::new())\n///     .component(SyncComponent::new())\n///     .build()\n///     .await?;\n///\n/// daemon.run().await?;\n/// ```\npub struct DaemonBuilder {\n    settings: Settings,\n    store: Option<SqliteStore>,\n    history_db: Option<HistoryDatabase>,\n    components: Vec<Box<dyn Component>>,\n}\n\nimpl DaemonBuilder {\n    /// Create a new daemon builder with the given settings.\n    pub fn new(settings: Settings) -> Self {\n        Self {\n            settings,\n            store: None,\n            history_db: None,\n            components: Vec::new(),\n        }\n    }\n\n    /// Set the record store.\n    pub fn store(mut self, store: SqliteStore) -> Self {\n        self.store = Some(store);\n        self\n    }\n\n    /// Set the history database.\n    pub fn history_db(mut self, db: HistoryDatabase) -> Self {\n        self.history_db = Some(db);\n        self\n    }\n\n    /// Register a component.\n    ///\n    /// Components are started in registration order and stopped in reverse order.\n    pub fn component(mut self, component: impl Component + 'static) -> Self {\n        self.components.push(Box::new(component));\n        self\n    }\n\n    /// Build the daemon.\n    ///\n    /// This loads the encryption key and creates the daemon state.\n    pub async fn build(self) -> Result<Daemon> {\n        let store = self.store.ok_or_else(|| eyre::eyre!(\"store is required\"))?;\n        let history_db = self\n            .history_db\n            .ok_or_else(|| eyre::eyre!(\"history_db is required\"))?;\n\n        // Load encryption key\n        let encryption_key: [u8; 32] = encryption::load_key(&self.settings)\n            .context(\"could not load encryption key\")?\n            .into();\n\n        // Create the event bus\n        let (event_tx, _) = broadcast::channel(64);\n\n        // Create the shared state\n        let state = Arc::new(DaemonState {\n            event_tx,\n            settings: RwLock::new(self.settings),\n            encryption_key,\n            history_db,\n            store,\n        });\n\n        // Create the handle (just a reference to the state)\n        let handle = DaemonHandle { state };\n\n        Ok(Daemon {\n            components: self.components,\n            handle,\n        })\n    }\n}\n"
  },
  {
    "path": "crates/atuin-daemon/src/events.rs",
    "content": "//! Daemon events.\n//!\n//! Events are the primary communication mechanism within the daemon.\n//! Components emit events to notify others of state changes, and handle\n//! events to react to changes elsewhere in the system.\n//!\n//! External processes (like CLI commands) can also inject events via the\n//! Control gRPC service.\n\nuse atuin_client::history::{History, HistoryId};\nuse atuin_common::record::RecordId;\n\n/// Events that flow through the daemon's event bus.\n///\n/// Events are broadcast to all components. Each component decides which\n/// events it cares about in its `handle_event` implementation.\n#[derive(Debug, Clone)]\npub enum DaemonEvent {\n    // ---- History lifecycle ----\n    /// A command has started running.\n    HistoryStarted(History),\n\n    /// A command has finished running.\n    HistoryEnded(History),\n\n    // ---- Sync ----\n    /// Records were synced from the server.\n    ///\n    /// The search component uses this to update its index with new history.\n    RecordsAdded(Vec<RecordId>),\n\n    /// Sync completed successfully.\n    SyncCompleted {\n        /// Number of records uploaded.\n        uploaded: usize,\n        /// Number of records downloaded.\n        downloaded: usize,\n    },\n\n    /// Sync failed.\n    SyncFailed {\n        /// Error message describing what went wrong.\n        error: String,\n    },\n\n    /// Request an immediate sync (external trigger).\n    ForceSync,\n\n    // ---- External commands ----\n    /// History was pruned - search index needs a full rebuild.\n    ///\n    /// Emitted when the user runs `atuin history prune` or similar.\n    HistoryPruned,\n\n    /// History was rebuilt - search index needs a full rebuild.\n    ///\n    /// Emitted when the user runs `atuin store rebuild history` or similar.\n    HistoryRebuilt,\n\n    /// Specific history items were deleted.\n    ///\n    /// The search component should remove these from its index.\n    HistoryDeleted {\n        /// IDs of the deleted history entries.\n        ids: Vec<HistoryId>,\n    },\n\n    /// Settings have changed, components should reload if needed.\n    SettingsReloaded,\n\n    // ---- Lifecycle ----\n    /// Request graceful shutdown of the daemon.\n    ShutdownRequested,\n}\n"
  },
  {
    "path": "crates/atuin-daemon/src/history/mod.rs",
    "content": "//! History module for the daemon gRPC history service.\n//!\n//! This module contains the proto-generated types for the history gRPC service.\n\n// Include the generated proto code\ntonic::include_proto!(\"history\");\n"
  },
  {
    "path": "crates/atuin-daemon/src/lib.rs",
    "content": "use atuin_client::database::Sqlite as HistoryDatabase;\nuse atuin_client::record::sqlite_store::SqliteStore;\nuse atuin_client::settings::{Settings, watcher::global_settings_watcher};\nuse eyre::Result;\n\npub mod client;\npub mod components;\npub mod control;\npub mod daemon;\npub mod events;\npub mod history;\npub mod search;\npub mod server;\n\n// Re-export core daemon types for convenience\npub use daemon::{Component, Daemon, DaemonBuilder, DaemonHandle};\npub use events::DaemonEvent;\n\n// Re-export components\npub use components::{HistoryComponent, SearchComponent, SyncComponent};\n\n// Re-export client helpers\npub use client::{ControlClient, emit_event, emit_event_with_settings};\n\n/// Boot the daemon using the new component-based architecture.\n///\n/// This creates a daemon with the standard components (history, search, sync),\n/// starts the gRPC server with their services, and runs the event loop.\npub async fn boot(\n    settings: Settings,\n    store: SqliteStore,\n    history_db: HistoryDatabase,\n) -> Result<()> {\n    // Create the components\n    let history_component = HistoryComponent::new();\n    let search_component = SearchComponent::new();\n    let sync_component = SyncComponent::new();\n\n    // Get the gRPC services before moving components into the daemon\n    // (The services share state with the components via Arc)\n    let history_service = history_component.grpc_service();\n    let search_service = search_component.grpc_service();\n\n    // Build the daemon\n    let mut daemon = Daemon::builder(settings.clone())\n        .store(store)\n        .history_db(history_db)\n        .component(history_component)\n        .component(search_component)\n        .component(sync_component)\n        .build()\n        .await?;\n\n    // Get a handle for the control service and gRPC server shutdown\n    let handle = daemon.handle();\n\n    // Create the control service\n    let control_service = control::ControlService::new(handle.clone());\n\n    // Start all components first (so gRPC services can work)\n    daemon.start_components().await?;\n\n    // Spawn config file watcher to reload settings on changes\n    if let Ok(watcher) = global_settings_watcher() {\n        let mut settings_rx = watcher.subscribe();\n        let watcher_handle = handle.clone();\n        tokio::spawn(async move {\n            tracing::info!(\"config file watcher started\");\n            while settings_rx.changed().await.is_ok() {\n                // Use the already-loaded settings from the watcher\n                // (avoids parsing the config file twice)\n                let new_settings = (*settings_rx.borrow()).clone();\n                watcher_handle.apply_settings((*new_settings).clone()).await;\n            }\n            tracing::debug!(\"config file watcher stopped\");\n        });\n    } else {\n        tracing::warn!(\n            \"failed to start config file watcher; settings changes will require daemon restart\"\n        );\n    }\n\n    // Spawn signal handler to emit ShutdownRequested on Ctrl+C/SIGTERM\n    let signal_handle = handle.clone();\n    tokio::spawn(async move {\n        shutdown_signal().await;\n        tracing::info!(\"received shutdown signal\");\n        signal_handle.shutdown();\n    });\n\n    // Start the gRPC server in the background\n    server::run_grpc_server(\n        settings,\n        history_service,\n        search_service,\n        control_service.into_server(),\n        handle,\n    )\n    .await?;\n\n    // Run the daemon event loop\n    daemon.run_event_loop().await?;\n\n    // Stop all components on shutdown\n    daemon.stop_components().await;\n\n    tracing::info!(\"daemon shut down complete\");\n    Ok(())\n}\n\n/// Wait for a shutdown signal (Ctrl+C or SIGTERM).\n#[cfg(unix)]\nasync fn shutdown_signal() {\n    let mut term = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())\n        .expect(\"failed to register sigterm handler\");\n    let mut int = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt())\n        .expect(\"failed to register sigint handler\");\n\n    tokio::select! {\n        _ = term.recv() => {},\n        _ = int.recv() => {},\n    }\n}\n\n/// Wait for a shutdown signal (Ctrl+C).\n#[cfg(not(unix))]\nasync fn shutdown_signal() {\n    tokio::signal::ctrl_c()\n        .await\n        .expect(\"failed to listen for ctrl+c\");\n}\n"
  },
  {
    "path": "crates/atuin-daemon/src/search/index.rs",
    "content": "//! Search index with frecency-based ranking.\n//!\n//! This module provides a deduplicated search index where each unique command\n//! is stored once, with metadata about all its invocations. This enables:\n//!\n//! - Efficient fuzzy matching (fewer items to match)\n//! - Frecency-based ranking (frequency + recency)\n//! - Dynamic filtering by directory, host, session, etc.\n\nuse std::{\n    collections::{HashMap, HashSet},\n    sync::Arc,\n};\n\nuse atuin_client::history::History;\nuse atuin_client::settings::Search;\nuse atuin_nucleo::{Injector, Nucleo, pattern};\nuse dashmap::DashMap;\nuse lasso::{Spur, ThreadedRodeo};\nuse time::OffsetDateTime;\nuse tokio::sync::RwLock;\nuse tracing::{Level, instrument};\nuse uuid::Uuid;\n\nuse crate::components::search::with_trailing_slash;\n\n/// Parse a UUID string into a 16-byte array.\n/// Returns None if the string is not a valid UUID.\nfn parse_uuid_bytes(s: &str) -> Option<[u8; 16]> {\n    Uuid::parse_str(s).ok().map(|u| *u.as_bytes())\n}\n\n/// Format a 16-byte array as a UUID string.\nfn format_uuid_bytes(bytes: &[u8; 16]) -> String {\n    Uuid::from_bytes(*bytes).to_string()\n}\n\n/// Pre-computed frecency data for O(1) lookup.\n#[derive(Debug, Clone, Default)]\npub struct FrecencyData {\n    /// Total number of times this command was used.\n    pub count: u32,\n    /// Most recent usage timestamp (unix seconds).\n    pub last_used: i64,\n}\n\nimpl FrecencyData {\n    /// Record a new usage of this command.\n    pub fn record_use(&mut self, timestamp: i64) {\n        self.count += 1;\n        if timestamp > self.last_used {\n            self.last_used = timestamp;\n        }\n    }\n\n    /// Compute frecency score based on count and recency.\n    ///\n    /// Uses a decay function where more recent commands score higher.\n    /// The formula balances frequency (how often) with recency (how recent).\n    ///\n    /// Multipliers allow tuning the relative weights:\n    /// - `recency_mul`: Multiplier for recency score (default: 1.0)\n    /// - `frequency_mul`: Multiplier for frequency score (default: 1.0)\n    ///\n    /// A multiplier of 0.0 disables that component, 1.0 is unchanged, 2.0 doubles weight.\n    /// Values like 0.5 reduce weight by half, 1.5 increases by 50%, etc.\n    #[instrument(level = tracing::Level::TRACE, name = \"index_frecency_compute\")]\n    pub fn compute(&self, now: i64, recency_mul: f64, frequency_mul: f64) -> u32 {\n        if self.count == 0 {\n            return 0;\n        }\n\n        // Time-based decay: score decreases as time passes\n        let age_seconds = (now - self.last_used).max(0) as u64;\n        let age_hours = age_seconds / 3600;\n\n        // Decay factor: recent commands get higher scores\n        // - Last hour: multiplier ~1.0\n        // - Last day: multiplier ~0.5\n        // - Last week: multiplier ~0.1\n        // - Older: multiplier approaches 0\n        let recency_score: f64 = match age_hours {\n            0 => 100.0,\n            1..=6 => 90.0,\n            7..=24 => 70.0,\n            25..=72 => 50.0,\n            73..=168 => 30.0,\n            169..=720 => 15.0,\n            _ => 5.0,\n        };\n\n        // Frequency boost: more uses = higher score (with diminishing returns)\n        let frequency_score = ((self.count as f64).ln() * 20.0).min(100.0);\n\n        // Apply multipliers and combine scores, then round to u32\n        ((recency_score * recency_mul) + (frequency_score * frequency_mul)).round() as u32\n    }\n}\n\n/// Data for a unique command.\npub struct CommandData {\n    /// History ID of the most recent invocation (16-byte UUID).\n    most_recent_id: [u8; 16],\n    /// Timestamp of the most recent invocation.\n    most_recent_timestamp: i64,\n    /// Pre-computed global frecency.\n    pub global_frecency: FrecencyData,\n\n    // Pre-computed indexes for O(1) filter lookups\n    // Using HashSet instead of DashSet since CommandData lives inside DashMap (already synchronized)\n    /// All directories where this command has been run (interned keys).\n    directories: HashSet<Spur>,\n    /// All hostnames where this command has been run (interned keys).\n    hosts: HashSet<Spur>,\n    /// All sessions where this command has been run (as 16-byte UUIDs).\n    sessions: HashSet<[u8; 16]>,\n}\n\nimpl CommandData {\n    /// Create a new CommandData from a history entry.\n    /// Returns None if the history entry has invalid UUIDs.\n    pub fn new(history: &History, interner: &ThreadedRodeo) -> Option<Self> {\n        let history_id = parse_uuid_bytes(&history.id.0)?;\n        let session = parse_uuid_bytes(&history.session)?;\n        let timestamp = history.timestamp.unix_timestamp();\n\n        let dir_key = interner.get_or_intern(with_trailing_slash(&history.cwd));\n        let host_key = interner.get_or_intern(&history.hostname);\n\n        let mut directories = HashSet::new();\n        directories.insert(dir_key);\n\n        let mut hosts = HashSet::new();\n        hosts.insert(host_key);\n\n        let mut sessions = HashSet::new();\n        sessions.insert(session);\n\n        let mut global_frecency = FrecencyData::default();\n        global_frecency.record_use(timestamp);\n\n        Some(Self {\n            most_recent_id: history_id,\n            most_recent_timestamp: timestamp,\n            global_frecency,\n            directories,\n            hosts,\n            sessions,\n        })\n    }\n\n    /// Add an invocation from a history entry.\n    /// Returns false if the history entry has invalid UUIDs.\n    pub fn add_invocation(&mut self, history: &History, interner: &ThreadedRodeo) -> bool {\n        let Some(history_id) = parse_uuid_bytes(&history.id.0) else {\n            return false;\n        };\n        let Some(session) = parse_uuid_bytes(&history.session) else {\n            return false;\n        };\n\n        let timestamp = history.timestamp.unix_timestamp();\n\n        // Update global frecency\n        self.global_frecency.record_use(timestamp);\n\n        // Update pre-computed indexes for O(1) filter lookups\n        let dir_key = interner.get_or_intern(with_trailing_slash(&history.cwd));\n        self.directories.insert(dir_key);\n        self.hosts.insert(interner.get_or_intern(&history.hostname));\n        self.sessions.insert(session);\n\n        // Update most recent if this invocation is newer\n        if timestamp > self.most_recent_timestamp {\n            self.most_recent_id = history_id;\n            self.most_recent_timestamp = timestamp;\n        }\n\n        true\n    }\n\n    /// Get the most recent history ID for this command.\n    pub fn most_recent_id(&self) -> String {\n        format_uuid_bytes(&self.most_recent_id)\n    }\n\n    /// Check if any invocation matches a directory filter (exact match).\n    /// O(1) lookup using pre-computed index.\n    pub fn has_invocation_in_dir(&self, dir: &str, interner: &ThreadedRodeo) -> bool {\n        interner\n            .get(dir)\n            .is_some_and(|spur| self.directories.contains(&spur))\n    }\n\n    /// Check if any invocation matches a directory prefix (workspace/git root).\n    /// O(n) where n = number of unique directories for this command.\n    pub fn has_invocation_in_workspace(&self, prefix: &str, interner: &ThreadedRodeo) -> bool {\n        self.directories\n            .iter()\n            .any(|&spur| interner.resolve(&spur).starts_with(prefix))\n    }\n\n    /// Check if any invocation matches a hostname.\n    /// O(1) lookup using pre-computed index.\n    pub fn has_invocation_on_host(&self, hostname: &str, interner: &ThreadedRodeo) -> bool {\n        interner\n            .get(hostname)\n            .is_some_and(|spur| self.hosts.contains(&spur))\n    }\n\n    /// Check if any invocation matches a session.\n    /// O(1) lookup using pre-computed index.\n    pub fn has_invocation_in_session(&self, session: &str) -> bool {\n        parse_uuid_bytes(session).is_some_and(|bytes| self.sessions.contains(&bytes))\n    }\n}\n\n/// Filter mode for search queries.\n#[derive(Debug, Clone, PartialEq, Eq)]\npub enum IndexFilterMode {\n    /// No filtering - search all commands.\n    Global,\n    /// Filter to commands run in a specific directory.\n    Directory(String),\n    /// Filter to commands run in a workspace (directory prefix).\n    Workspace(String),\n    /// Filter to commands run on a specific host.\n    Host(String),\n    /// Filter to commands run in a specific session.\n    Session(String),\n}\n\n/// Context for search queries.\n#[derive(Debug, Clone, Default)]\npub struct QueryContext {\n    pub cwd: Option<String>,\n    pub git_root: Option<String>,\n    pub hostname: Option<String>,\n    pub session_id: Option<String>,\n}\n\n/// Shareable frecency map: command -> frecency score.\n/// Wrapped in Arc for zero-copy sharing with scorer callbacks.\ntype FrecencyMap = Arc<HashMap<Arc<str>, u32>>;\n\n/// A deduplicated search index with frecency-based ranking.\n///\n/// Commands are stored by their text, with metadata about all invocations.\n/// Nucleo handles fuzzy matching, while frecency is computed via scorer callback.\n///\n/// Global frecency is precomputed by a background task and used for scoring.\n/// If frecency data is not available, search still works but without frecency ranking;\n/// although this should never happen due to precomputing the frecency map.\npub struct SearchIndex {\n    /// Map from command text to command data.\n    /// Using DashMap for concurrent read/write access, wrapped in Arc for sharing with scorer.\n    /// Keys are Arc<str> to enable zero-copy sharing with frecency_map.\n    commands: Arc<DashMap<Arc<str>, CommandData>>,\n    /// Nucleo fuzzy matcher - items are command strings.\n    nucleo: RwLock<Nucleo<String>>,\n    /// Injector for adding new commands to Nucleo.\n    injector: Injector<String>,\n    /// Precomputed global frecency map. Updated by background task.\n    frecency_map: RwLock<Option<FrecencyMap>>,\n    /// String interner for deduplicating cwd, hostname, and directory paths.\n    interner: Arc<ThreadedRodeo>,\n}\n\nimpl SearchIndex {\n    /// Create a new empty search index.\n    pub fn new() -> Self {\n        let nucleo_config = atuin_nucleo::Config::DEFAULT;\n        // Single column for command text\n        let nucleo = Nucleo::<String>::new(nucleo_config, Arc::new(|| {}), None, 1);\n        let injector = nucleo.injector();\n\n        Self {\n            commands: Arc::new(DashMap::new()),\n            nucleo: RwLock::new(nucleo),\n            injector,\n            frecency_map: RwLock::new(None),\n            interner: Arc::new(ThreadedRodeo::new()),\n        }\n    }\n\n    /// Add a history entry to the index.\n    ///\n    /// If the command already exists, updates its invocation data.\n    /// If it's a new command, adds it to both the map and Nucleo.\n    pub fn add_history(&self, history: &History) {\n        let command = history.command.as_str();\n\n        // DashMap with Arc<str> keys can be looked up with &str via Borrow trait\n        if let Some(mut entry) = self.commands.get_mut(command) {\n            // Existing command - just update invocations\n            entry.add_invocation(history, &self.interner);\n        } else {\n            // New command - create Arc<str> once and share it\n            let Some(data) = CommandData::new(history, &self.interner) else {\n                return; // Invalid UUIDs, skip this entry\n            };\n            let command_arc: Arc<str> = command.into();\n            self.commands.insert(Arc::clone(&command_arc), data);\n            // Nucleo still needs String (unavoidable copy for fuzzy matching)\n            self.injector.push(command_arc.to_string(), |cmd, cols| {\n                cols[0] = cmd.clone().into();\n            });\n        }\n        // Note: frecency_map is rebuilt by background task, not invalidated here\n    }\n\n    /// Add multiple history entries to the index.\n    pub fn add_histories(&self, histories: &[History]) {\n        for history in histories {\n            self.add_history(history);\n        }\n    }\n\n    /// Get the number of unique commands in the index.\n    pub fn command_count(&self) -> usize {\n        self.commands.len()\n    }\n\n    /// Get the number of items in Nucleo (should match command_count).\n    pub async fn nucleo_item_count(&self) -> u32 {\n        self.nucleo.read().await.snapshot().item_count()\n    }\n\n    /// Search for commands matching a query.\n    ///\n    /// Returns a list of history IDs (most recent invocation per command).\n    /// Uses precomputed global frecency for scoring if available.\n    #[instrument(skip_all, level = tracing::Level::TRACE, name = \"index_search\", fields(query = %query))]\n    pub async fn search(\n        &self,\n        query: &str,\n        filter_mode: IndexFilterMode,\n        _context: &QueryContext,\n        limit: u32,\n    ) -> Vec<String> {\n        let mut nucleo = self.nucleo.write().await;\n\n        // Get precomputed frecency map (may be None if not yet computed)\n        let frecency_map = self.frecency_map.read().await.clone();\n\n        // Build filter based on mode\n        let filter = self.build_filter(&filter_mode);\n        nucleo.set_filter(filter);\n\n        // Build scorer from precomputed frecency (or None if not available)\n        let scorer = Self::build_scorer(frecency_map);\n        nucleo.set_scorer(scorer);\n\n        // Update pattern\n        nucleo.pattern.reparse(\n            0,\n            query,\n            pattern::CaseMatching::Smart,\n            pattern::Normalization::Smart,\n            false,\n        );\n\n        tracing::span!(Level::TRACE, \"index_search_tick\").in_scope(|| {\n            // Tick until complete\n            while nucleo.tick(10).running {}\n        });\n\n        // Collect results\n        let snapshot = nucleo.snapshot();\n        let matched_count = snapshot.matched_item_count().min(limit);\n\n        tracing::span!(Level::TRACE, \"index_search_results\").in_scope(|| {\n            snapshot\n                .matched_items(..matched_count)\n                .filter_map(|item| {\n                    let cmd = item.data;\n                    // DashMap<Arc<str>, _>::get accepts &str via Borrow trait\n                    self.commands\n                        .get(cmd.as_str())\n                        .map(|data| data.most_recent_id())\n                })\n                .collect()\n        })\n    }\n\n    /// Rebuild the global frecency map.\n    ///\n    /// This should be called by a background task periodically.\n    /// The map is used for scoring search results.\n    ///\n    /// Uses multipliers from search settings:\n    /// - `recency_score_multiplier`: Weight for recency component\n    /// - `frequency_score_multiplier`: Weight for frequency component\n    /// - `frecency_score_multiplier`: Overall multiplier for final score\n    #[instrument(skip_all, level = tracing::Level::DEBUG, name = \"rebuild_frecency\")]\n    pub async fn rebuild_frecency(&self, search_settings: &Search) {\n        let now = OffsetDateTime::now_utc().unix_timestamp();\n        let mut frecency_map: HashMap<Arc<str>, u32> = HashMap::new();\n\n        // Clamp multipliers to non-negative values to prevent broken frecency ranking\n        // (negative values would produce unexpected results when cast to u32)\n        let recency_mul = search_settings.recency_score_multiplier.max(0.0);\n        let frequency_mul = search_settings.frequency_score_multiplier.max(0.0);\n        let frecency_mul = search_settings.frecency_score_multiplier.max(0.0);\n\n        for entry in self.commands.iter() {\n            let frecency = entry\n                .global_frecency\n                .compute(now, recency_mul, frequency_mul);\n            // Apply overall frecency multiplier and round to u32\n            let frecency = (frecency as f64 * frecency_mul).round() as u32;\n            // Arc::clone is cheap - just increments reference count\n            frecency_map.insert(Arc::clone(entry.key()), frecency);\n        }\n\n        *self.frecency_map.write().await = Some(Arc::new(frecency_map));\n    }\n\n    /// Build filter predicate for the given mode.\n    fn build_filter(&self, mode: &IndexFilterMode) -> Option<atuin_nucleo::Filter<String>> {\n        // For Global mode, no filter needed\n        if matches!(mode, IndexFilterMode::Global) {\n            return None;\n        }\n\n        // Pre-compute which commands pass the filter\n        // Use HashSet<String> for the short-lived filter (simpler than Arc lookup)\n        let passing_commands: Arc<HashSet<String>> = {\n            let mut set = HashSet::new();\n            for entry in self.commands.iter() {\n                let passes = match mode {\n                    IndexFilterMode::Global => unreachable!(),\n                    IndexFilterMode::Directory(dir) => {\n                        entry.has_invocation_in_dir(dir, &self.interner)\n                    }\n                    IndexFilterMode::Workspace(prefix) => {\n                        entry.has_invocation_in_workspace(prefix, &self.interner)\n                    }\n                    IndexFilterMode::Host(hostname) => {\n                        entry.has_invocation_on_host(hostname, &self.interner)\n                    }\n                    IndexFilterMode::Session(session) => entry.has_invocation_in_session(session),\n                };\n                if passes {\n                    // Convert Arc<str> to String for filter lookup\n                    set.insert(entry.key().to_string());\n                }\n            }\n            Arc::new(set)\n        };\n\n        Some(Arc::new(move |cmd: &String| passing_commands.contains(cmd)))\n    }\n\n    /// Build scorer from precomputed frecency map.\n    ///\n    /// Returns None if frecency map is not available (search still works, just without frecency ranking).\n    fn build_scorer(frecency_map: Option<FrecencyMap>) -> Option<atuin_nucleo::Scorer<String>> {\n        let map = frecency_map?;\n        Some(Arc::new(move |cmd: &String, fuzzy_score: u32| {\n            // HashMap<Arc<str>, _>::get accepts &str via Borrow trait\n            let frecency = map.get(cmd.as_str()).copied().unwrap_or(0);\n            fuzzy_score + frecency\n        }))\n    }\n}\n\nimpl Default for SearchIndex {\n    fn default() -> Self {\n        Self::new()\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n    use time::macros::datetime;\n\n    fn make_history(command: &str, cwd: &str, timestamp: OffsetDateTime) -> History {\n        History::import()\n            .timestamp(timestamp)\n            .command(command)\n            .cwd(cwd)\n            .build()\n            .into()\n    }\n\n    #[test]\n    fn frecency_data_compute() {\n        let now = 1000000i64;\n\n        // Recent command (with default multipliers of 1.0)\n        let recent = FrecencyData {\n            count: 5,\n            last_used: now - 60, // 1 minute ago\n        };\n        assert!(recent.compute(now, 1.0, 1.0) > 100); // High score\n\n        // Old command\n        let old = FrecencyData {\n            count: 5,\n            last_used: now - 86400 * 30, // 30 days ago\n        };\n        assert!(old.compute(now, 1.0, 1.0) < recent.compute(now, 1.0, 1.0));\n\n        // Frequently used old command\n        let frequent_old = FrecencyData {\n            count: 100,\n            last_used: now - 86400 * 7, // 1 week ago\n        };\n        // Should still have decent score due to frequency\n        assert!(frequent_old.compute(now, 1.0, 1.0) > 50);\n    }\n\n    #[test]\n    fn frecency_data_compute_with_multipliers() {\n        let now = 1000000i64;\n\n        let data = FrecencyData {\n            count: 5,\n            last_used: now - 60, // 1 minute ago (recency_score = 100)\n        };\n\n        // Default multipliers (1.0, 1.0)\n        let default_score = data.compute(now, 1.0, 1.0);\n\n        // Double recency weight\n        let double_recency = data.compute(now, 2.0, 1.0);\n        assert!(double_recency > default_score);\n\n        // Double frequency weight\n        let double_frequency = data.compute(now, 1.0, 2.0);\n        assert!(double_frequency > default_score);\n\n        // Zero out recency (only frequency counts)\n        let no_recency = data.compute(now, 0.0, 1.0);\n        assert!(no_recency < default_score);\n\n        // Zero out frequency (only recency counts)\n        let no_frequency = data.compute(now, 1.0, 0.0);\n        assert!(no_frequency < default_score);\n\n        // Zero both (should be zero)\n        let no_score = data.compute(now, 0.0, 0.0);\n        assert_eq!(no_score, 0);\n\n        // Fractional multipliers\n        let half_recency = data.compute(now, 0.5, 1.0);\n        assert!(half_recency < default_score);\n        assert!(half_recency > no_recency);\n\n        // 1.5x multiplier\n        let boost_recency = data.compute(now, 1.5, 1.0);\n        assert!(boost_recency > default_score);\n        assert!(boost_recency < double_recency);\n    }\n\n    #[test]\n    fn command_data_add_invocation() {\n        let interner = ThreadedRodeo::new();\n\n        let (dir1, dir2) = if cfg!(windows) {\n            (\"C:\\\\Users\\\\User\\\\project\", \"C:\\\\Users\\\\User\\\\other\")\n        } else {\n            (\"/home/user/project\", \"/home/user/other\")\n        };\n\n        let history1 = make_history(\"git status\", dir1, datetime!(2024-01-01 10:00 UTC));\n        let history2 = make_history(\"git status\", dir2, datetime!(2024-01-01 12:00 UTC));\n\n        let mut data = CommandData::new(&history1, &interner).unwrap();\n        assert_eq!(data.global_frecency.count, 1);\n        let id1 = data.most_recent_id();\n\n        data.add_invocation(&history2, &interner);\n        assert_eq!(data.global_frecency.count, 2);\n\n        // Most recent ID should update to history2 (newer timestamp)\n        let id2 = data.most_recent_id();\n        assert_ne!(id1, id2);\n    }\n\n    #[test]\n    fn command_data_filters() {\n        let interner = ThreadedRodeo::new();\n\n        let (dir1, dir2) = if cfg!(windows) {\n            (\"C:\\\\Users\\\\User\\\\project\", \"C:\\\\Users\\\\User\\\\other\")\n        } else {\n            (\"/home/user/project\", \"/home/user/other\")\n        };\n\n        let h1 = make_history(\"git status\", dir1, datetime!(2024-01-01 10:00 UTC));\n        let h2 = make_history(\"git status\", dir2, datetime!(2024-01-01 12:00 UTC));\n\n        let mut data = CommandData::new(&h1, &interner).unwrap();\n        data.add_invocation(&h2, &interner);\n\n        let (check1, check2, check3) = if cfg!(windows) {\n            (\n                with_trailing_slash(\"C:\\\\Users\\\\User\\\\project\"),\n                with_trailing_slash(\"C:\\\\Users\\\\User\\\\other\"),\n                with_trailing_slash(\"C:\\\\Users\\\\User\\\\missing\"),\n            )\n        } else {\n            (\n                with_trailing_slash(\"/home/user/project\"),\n                with_trailing_slash(\"/home/user/other\"),\n                with_trailing_slash(\"/home/user/missing\"),\n            )\n        };\n\n        assert!(data.has_invocation_in_dir(&check1, &interner));\n        assert!(data.has_invocation_in_dir(&check2, &interner));\n        assert!(!data.has_invocation_in_dir(&check3, &interner));\n\n        let (check1, check2, check3) = if cfg!(windows) {\n            (\n                with_trailing_slash(\"C:\\\\Users\\\\User\"),\n                with_trailing_slash(\"C:\\\\Users\"),\n                with_trailing_slash(\"C:\\\\Users\\\\User\\\\var\"),\n            )\n        } else {\n            (\n                with_trailing_slash(\"/home/user\"),\n                with_trailing_slash(\"/home\"),\n                with_trailing_slash(\"/var\"),\n            )\n        };\n\n        assert!(data.has_invocation_in_workspace(&check1, &interner));\n        assert!(data.has_invocation_in_workspace(&check2, &interner));\n        assert!(!data.has_invocation_in_workspace(&check3, &interner));\n    }\n\n    #[tokio::test]\n    async fn search_index_add_and_search() {\n        let index = SearchIndex::new();\n\n        let h1 = make_history(\n            \"git status\",\n            \"/home/user/project\",\n            datetime!(2024-01-01 10:00 UTC),\n        );\n        let h2 = make_history(\n            \"git commit -m 'test'\",\n            \"/home/user/project\",\n            datetime!(2024-01-01 10:05 UTC),\n        );\n        let h3 = make_history(\n            \"ls -la\",\n            \"/home/user/other\",\n            datetime!(2024-01-01 10:10 UTC),\n        );\n\n        index.add_history(&h1);\n        index.add_history(&h2);\n        index.add_history(&h3);\n\n        assert_eq!(index.command_count(), 3);\n\n        // Search for \"git\" - should match 2 commands\n        let results = index\n            .search(\"git\", IndexFilterMode::Global, &QueryContext::default(), 10)\n            .await;\n        assert_eq!(results.len(), 2);\n\n        // Search with directory filter\n        let results = index\n            .search(\n                \"\",\n                IndexFilterMode::Directory(with_trailing_slash(\"/home/user/project\")),\n                &QueryContext::default(),\n                10,\n            )\n            .await;\n        assert_eq!(results.len(), 2); // git status and git commit\n    }\n}\n"
  },
  {
    "path": "crates/atuin-daemon/src/search/mod.rs",
    "content": "//! Search module for the daemon gRPC search service.\n//!\n//! This module provides fuzzy search over command history using Nucleo.\n\nmod index;\n\n// Include the generated proto code\ntonic::include_proto!(\"search\");\n\n// Re-export the service and index\npub use index::{IndexFilterMode, QueryContext, SearchIndex};\n"
  },
  {
    "path": "crates/atuin-daemon/src/server.rs",
    "content": "use eyre::Result;\n\nuse crate::components::history::HistoryGrpcService;\nuse crate::components::search::SearchGrpcService;\nuse crate::control::{ControlService, control_server::ControlServer};\nuse crate::daemon::DaemonHandle;\nuse crate::history::history_server::HistoryServer;\nuse crate::search::search_server::SearchServer;\n\nuse atuin_client::settings::Settings;\n\n/// Run the gRPC server with the given services.\n///\n/// This starts the gRPC server in the background and returns immediately.\n/// The server will shut down when a ShutdownRequested event is received.\n#[cfg(unix)]\npub async fn run_grpc_server(\n    settings: Settings,\n    history_service: HistoryServer<HistoryGrpcService>,\n    search_service: SearchServer<SearchGrpcService>,\n    control_service: ControlServer<ControlService>,\n    handle: DaemonHandle,\n) -> Result<()> {\n    use tokio::net::UnixListener;\n    use tokio_stream::wrappers::UnixListenerStream;\n\n    let socket_path = settings.daemon.socket_path.clone();\n\n    let (uds, cleanup) = if cfg!(target_os = \"linux\") && settings.daemon.systemd_socket {\n        #[cfg(target_os = \"linux\")]\n        {\n            use eyre::{OptionExt, WrapErr};\n            use std::os::unix::net::SocketAddr;\n            use std::path::PathBuf;\n            tracing::info!(\"getting systemd socket\");\n            let listener = listenfd::ListenFd::from_env()\n                .take_unix_listener(0)?\n                .ok_or_eyre(\"missing systemd socket\")?;\n            listener.set_nonblocking(true)?;\n            let actual_path: Result<PathBuf, eyre::Report> = listener\n                .local_addr()\n                .context(\"getting systemd socket's path\")\n                .and_then(|addr: SocketAddr| {\n                    addr.as_pathname()\n                        .ok_or_eyre(\"systemd socket missing path\")\n                        .map(|path: &std::path::Path| path.to_owned())\n                });\n            match actual_path {\n                Ok(actual_path) => {\n                    tracing::info!(\"listening on systemd socket: {actual_path:?}\");\n                    if actual_path != std::path::Path::new(&socket_path) {\n                        tracing::warn!(\n                            \"systemd socket is not at configured client path: {socket_path:?}\"\n                        );\n                    }\n                }\n                Err(err) => {\n                    tracing::warn!(\n                        \"could not detect systemd socket path, ensure that it's at the configured path: {socket_path:?}, error: {err:?}\"\n                    );\n                }\n            }\n            (UnixListener::from_std(listener)?, false)\n        }\n        #[cfg(not(target_os = \"linux\"))]\n        unreachable!()\n    } else {\n        tracing::info!(\"listening on unix socket {socket_path:?}\");\n        (UnixListener::bind(socket_path.clone())?, true)\n    };\n\n    let uds_stream = UnixListenerStream::new(uds);\n\n    // Create shutdown signal from daemon handle\n    let shutdown_signal = async move {\n        let mut rx = handle.subscribe();\n        loop {\n            use crate::DaemonEvent;\n\n            match rx.recv().await {\n                Ok(DaemonEvent::ShutdownRequested) => break,\n                Ok(_) => continue,\n                Err(_) => break, // Channel closed\n            }\n        }\n        if cleanup {\n            eprintln!(\"Removing socket...\");\n            if let Err(e) = std::fs::remove_file(&socket_path)\n                && e.kind() != std::io::ErrorKind::NotFound\n            {\n                eprintln!(\"failed to remove socket: {e}\");\n            }\n        }\n        eprintln!(\"Shutting down gRPC server...\");\n    };\n\n    // Spawn the server in the background\n    tokio::spawn(async move {\n        use tonic::transport::Server;\n\n        if let Err(e) = Server::builder()\n            .add_service(history_service)\n            .add_service(search_service)\n            .add_service(control_service)\n            .serve_with_incoming_shutdown(uds_stream, shutdown_signal)\n            .await\n        {\n            tracing::error!(\"gRPC server error: {e}\");\n        }\n    });\n\n    Ok(())\n}\n\n/// Run the gRPC server with the given services (Windows/TCP version).\n#[cfg(not(unix))]\npub async fn run_grpc_server(\n    settings: Settings,\n    history_service: HistoryServer<HistoryGrpcService>,\n    search_service: SearchServer<SearchGrpcService>,\n    control_service: ControlServer<ControlService>,\n    handle: DaemonHandle,\n) -> Result<()> {\n    use tokio::net::TcpListener;\n    use tokio_stream::wrappers::TcpListenerStream;\n    use tonic::transport::Server;\n\n    let port = settings.daemon.tcp_port;\n    let url = format!(\"127.0.0.1:{port}\");\n    let tcp = TcpListener::bind(&url).await?;\n    let tcp_stream = TcpListenerStream::new(tcp);\n\n    tracing::info!(\"listening on tcp port {:?}\", port);\n\n    // Create shutdown signal from daemon handle\n    let shutdown_signal = async move {\n        use crate::DaemonEvent;\n\n        let mut rx = handle.subscribe();\n        loop {\n            match rx.recv().await {\n                Ok(DaemonEvent::ShutdownRequested) => break,\n                Ok(_) => continue,\n                Err(_) => break, // Channel closed\n            }\n        }\n        eprintln!(\"Shutting down gRPC server...\");\n    };\n\n    // Spawn the server in the background\n    tokio::spawn(async move {\n        if let Err(e) = Server::builder()\n            .add_service(history_service)\n            .add_service(search_service)\n            .add_service(control_service)\n            .serve_with_incoming_shutdown(tcp_stream, shutdown_signal)\n            .await\n        {\n            tracing::error!(\"gRPC server error: {e}\");\n        }\n    });\n\n    Ok(())\n}\n"
  },
  {
    "path": "crates/atuin-daemon/tests/lifecycle.rs",
    "content": "//! Integration tests for the daemon server lifecycle.\n//!\n//! Each test spins up a real gRPC server on a temporary unix socket,\n//! connects a client, and exercises the daemon RPCs.\n\n#[cfg(unix)]\nmod unix {\n    use std::time::Duration;\n\n    use atuin_client::database::Sqlite;\n    use atuin_client::record::sqlite_store::SqliteStore;\n    use atuin_client::settings::{Settings, init_meta_config_for_testing};\n    use atuin_daemon::client::HistoryClient;\n    use atuin_daemon::components::HistoryComponent;\n    use atuin_daemon::{Daemon, DaemonHandle};\n    use tempfile::TempDir;\n    use tokio::net::UnixListener;\n    use tokio_stream::wrappers::UnixListenerStream;\n    use tonic::transport::Server;\n\n    /// Spins up a daemon server on a temp socket and returns a connected client,\n    /// the daemon handle (for shutdown), and the temp dir (must be held to keep paths alive).\n    async fn start_test_daemon() -> (HistoryClient, DaemonHandle, TempDir) {\n        let tmp = tempfile::tempdir().unwrap();\n\n        let db_path = tmp.path().join(\"history.db\");\n        let record_path = tmp.path().join(\"records.db\");\n        let key_path = tmp.path().join(\"key\");\n        let socket_path = tmp.path().join(\"test.sock\");\n        let meta_path = tmp.path().join(\"meta.db\");\n\n        // Initialize the meta store config for testing (required for Settings::host_id())\n        init_meta_config_for_testing(meta_path.to_str().unwrap(), 5.0);\n\n        // Build settings with test paths\n        let settings: Settings = Settings::builder()\n            .expect(\"could not build settings builder\")\n            .set_override(\"db_path\", db_path.to_str().unwrap())\n            .expect(\"failed to set db_path\")\n            .set_override(\"record_store_path\", record_path.to_str().unwrap())\n            .expect(\"failed to set record_store_path\")\n            .set_override(\"key_path\", key_path.to_str().unwrap())\n            .expect(\"failed to set key_path\")\n            .set_override(\"daemon.socket_path\", socket_path.to_str().unwrap())\n            .expect(\"failed to set socket_path\")\n            .set_override(\"meta.db_path\", meta_path.to_str().unwrap())\n            .expect(\"failed to set meta.db_path\")\n            .build()\n            .expect(\"could not build settings\")\n            .try_deserialize()\n            .expect(\"could not deserialize settings\");\n\n        // Create databases\n        let history_db = Sqlite::new(&db_path, 5.0).await.unwrap();\n        let store = SqliteStore::new(&record_path, 5.0).await.unwrap();\n\n        // Create the history component and get its gRPC service\n        let history_component = HistoryComponent::new();\n        let history_service = history_component.grpc_service();\n\n        // Build and start the daemon\n        let mut daemon = Daemon::builder(settings)\n            .store(store)\n            .history_db(history_db)\n            .component(history_component)\n            .build()\n            .await\n            .unwrap();\n\n        let handle = daemon.handle();\n\n        // Start components (this initializes the history component with the handle)\n        daemon.start_components().await.unwrap();\n\n        // Start the gRPC server\n        let uds = UnixListener::bind(&socket_path).unwrap();\n        let stream = UnixListenerStream::new(uds);\n\n        let server_handle = handle.clone();\n        tokio::spawn(async move {\n            let mut rx = server_handle.subscribe();\n            Server::builder()\n                .add_service(history_service)\n                .serve_with_incoming_shutdown(stream, async move {\n                    loop {\n                        match rx.recv().await {\n                            Ok(atuin_daemon::DaemonEvent::ShutdownRequested) => break,\n                            Ok(_) => continue,\n                            Err(_) => break,\n                        }\n                    }\n                })\n                .await\n                .unwrap();\n        });\n\n        // Spawn the daemon event loop in the background\n        tokio::spawn(async move {\n            daemon.run_event_loop().await.unwrap();\n        });\n\n        // Give the server a moment to bind.\n        tokio::time::sleep(Duration::from_millis(50)).await;\n\n        let client = HistoryClient::new(socket_path.to_string_lossy().to_string())\n            .await\n            .unwrap();\n\n        (client, handle, tmp)\n    }\n\n    #[tokio::test]\n    async fn test_status() {\n        let (mut client, _handle, _tmp) = start_test_daemon().await;\n\n        let status = client.status().await.unwrap();\n        assert!(status.healthy);\n        assert_eq!(status.version, env!(\"CARGO_PKG_VERSION\"));\n        assert_eq!(status.protocol, 1);\n        assert!(status.pid > 0);\n    }\n\n    #[tokio::test]\n    async fn test_start_end_history() {\n        use atuin_client::history::History;\n\n        let (mut client, _handle, _tmp) = start_test_daemon().await;\n\n        let history = History::daemon()\n            .timestamp(time::OffsetDateTime::now_utc())\n            .command(\"echo hello\".to_string())\n            .cwd(\"/tmp\".to_string())\n            .session(\"test-session\".to_string())\n            .hostname(\"test-host\".to_string())\n            .build()\n            .into();\n\n        let start_reply = client.start_history(history).await.unwrap();\n        assert!(!start_reply.id.is_empty());\n\n        let end_reply = client\n            .end_history(start_reply.id, 1_000_000, 0)\n            .await\n            .unwrap();\n        assert!(!end_reply.id.is_empty());\n    }\n\n    #[tokio::test]\n    async fn test_end_unknown_history_fails() {\n        let (mut client, _handle, _tmp) = start_test_daemon().await;\n\n        let result = client\n            .end_history(\"nonexistent-id\".to_string(), 1000, 0)\n            .await;\n        assert!(result.is_err());\n    }\n\n    #[tokio::test]\n    async fn test_shutdown() {\n        let (mut client, _handle, _tmp) = start_test_daemon().await;\n\n        let accepted = client.shutdown().await.unwrap();\n        assert!(accepted);\n\n        // Give server time to shut down.\n        tokio::time::sleep(Duration::from_millis(100)).await;\n\n        // Subsequent calls should fail since the server is gone.\n        let result = client.status().await;\n        assert!(result.is_err());\n    }\n}\n"
  },
  {
    "path": "crates/atuin-dotfiles/Cargo.toml",
    "content": "[package]\nname = \"atuin-dotfiles\"\ndescription = \"The dotfiles crate for Atuin\"\nedition = \"2024\"\nversion = { workspace = true }\n\nauthors.workspace = true\nrust-version.workspace = true\nlicense.workspace = true\nhomepage.workspace = true\nrepository.workspace = true\nreadme.workspace = true\n\n# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html\n\n[dependencies]\natuin-common = { path = \"../atuin-common\", version = \"18.13.3\" }\natuin-client = { path = \"../atuin-client\", version = \"18.13.3\" }\n\neyre = { workspace = true }\ntokio = { workspace = true }\nrmp = { version = \"0.8.14\" }\nrand = { workspace = true }\nserde = { workspace = true }\ncrypto_secretbox = \"0.1.1\"\n"
  },
  {
    "path": "crates/atuin-dotfiles/src/lib.rs",
    "content": "pub mod shell;\npub mod store;\n"
  },
  {
    "path": "crates/atuin-dotfiles/src/shell/bash.rs",
    "content": "use std::path::PathBuf;\n\nuse crate::store::{AliasStore, var::VarStore};\n\nasync fn cached_aliases(path: PathBuf, store: &AliasStore) -> String {\n    match tokio::fs::read_to_string(path).await {\n        Ok(aliases) => aliases,\n        Err(r) => {\n            // we failed to read the file for some reason, but the file does exist\n            // fallback to generating new aliases on the fly\n\n            store.posix().await.unwrap_or_else(|e| {\n                format!(\"echo 'Atuin: failed to read and generate aliases: \\n{r}\\n{e}'\",)\n            })\n        }\n    }\n}\n\nasync fn cached_vars(path: PathBuf, store: &VarStore) -> String {\n    match tokio::fs::read_to_string(path).await {\n        Ok(vars) => vars,\n        Err(r) => {\n            // we failed to read the file for some reason, but the file does exist\n            // fallback to generating new vars on the fly\n\n            store.posix().await.unwrap_or_else(|e| {\n                format!(\"echo 'Atuin: failed to read and generate vars: \\n{r}\\n{e}'\",)\n            })\n        }\n    }\n}\n\n/// Return bash dotfile config\n///\n/// Do not return an error. We should not prevent the shell from starting.\n///\n/// In the worst case, Atuin should not function but the shell should start correctly.\n///\n/// While currently this only returns aliases, it will be extended to also return other synced dotfiles\npub async fn alias_config(store: &AliasStore) -> String {\n    // First try to read the cached config\n    let aliases = atuin_common::utils::dotfiles_cache_dir().join(\"aliases.bash\");\n\n    if aliases.exists() {\n        return cached_aliases(aliases, store).await;\n    }\n\n    if let Err(e) = store.build().await {\n        return format!(\"echo 'Atuin: failed to generate aliases: {e}'\");\n    }\n\n    cached_aliases(aliases, store).await\n}\n\npub async fn var_config(store: &VarStore) -> String {\n    // First try to read the cached config\n    let vars = atuin_common::utils::dotfiles_cache_dir().join(\"vars.bash\");\n\n    if vars.exists() {\n        return cached_vars(vars, store).await;\n    }\n\n    if let Err(e) = store.build().await {\n        return format!(\"echo 'Atuin: failed to generate vars: {e}'\");\n    }\n\n    cached_vars(vars, store).await\n}\n"
  },
  {
    "path": "crates/atuin-dotfiles/src/shell/fish.rs",
    "content": "// Configuration for fish\nuse std::path::PathBuf;\n\nuse crate::store::{AliasStore, var::VarStore};\n\nasync fn cached_aliases(path: PathBuf, store: &AliasStore) -> String {\n    match tokio::fs::read_to_string(path).await {\n        Ok(aliases) => aliases,\n        Err(r) => {\n            // we failed to read the file for some reason, but the file does exist\n            // fallback to generating new aliases on the fly\n\n            store.posix().await.unwrap_or_else(|e| {\n                format!(\"echo 'Atuin: failed to read and generate aliases: \\n{r}\\n{e}'\",)\n            })\n        }\n    }\n}\n\nasync fn cached_vars(path: PathBuf, store: &VarStore) -> String {\n    match tokio::fs::read_to_string(path).await {\n        Ok(vars) => vars,\n        Err(r) => {\n            // we failed to read the file for some reason, but the file does exist\n            // fallback to generating new vars on the fly\n\n            store.posix().await.unwrap_or_else(|e| {\n                format!(\"echo 'Atuin: failed to read and generate vars: \\n{r}\\n{e}'\",)\n            })\n        }\n    }\n}\n\n/// Return fish dotfile config\n///\n/// Do not return an error. We should not prevent the shell from starting.\n///\n/// In the worst case, Atuin should not function but the shell should start correctly.\n///\n/// While currently this only returns aliases, it will be extended to also return other synced dotfiles\npub async fn alias_config(store: &AliasStore) -> String {\n    // First try to read the cached config\n    let aliases = atuin_common::utils::dotfiles_cache_dir().join(\"aliases.fish\");\n\n    if aliases.exists() {\n        return cached_aliases(aliases, store).await;\n    }\n\n    if let Err(e) = store.build().await {\n        return format!(\"echo 'Atuin: failed to generate aliases: {e}'\");\n    }\n\n    cached_aliases(aliases, store).await\n}\n\npub async fn var_config(store: &VarStore) -> String {\n    // First try to read the cached config\n    let vars = atuin_common::utils::dotfiles_cache_dir().join(\"vars.fish\");\n\n    if vars.exists() {\n        return cached_vars(vars, store).await;\n    }\n\n    if let Err(e) = store.build().await {\n        return format!(\"echo 'Atuin: failed to generate vars: {e}'\");\n    }\n\n    cached_vars(vars, store).await\n}\n"
  },
  {
    "path": "crates/atuin-dotfiles/src/shell/powershell.rs",
    "content": "use crate::shell::{Alias, Var};\nuse crate::store::{AliasStore, var::VarStore};\nuse std::path::PathBuf;\n\nasync fn cached_aliases(path: PathBuf, store: &AliasStore) -> String {\n    match tokio::fs::read_to_string(path).await {\n        Ok(aliases) => aliases,\n        Err(r) => {\n            // we failed to read the file for some reason, but the file does exist\n            // fallback to generating new aliases on the fly\n\n            store.powershell().await.unwrap_or_else(|e| {\n                format!(\"echo 'Atuin: failed to read and generate aliases: \\n{r}\\n{e}'\",)\n            })\n        }\n    }\n}\n\nasync fn cached_vars(path: PathBuf, store: &VarStore) -> String {\n    match tokio::fs::read_to_string(path).await {\n        Ok(vars) => vars,\n        Err(r) => {\n            // we failed to read the file for some reason, but the file does exist\n            // fallback to generating new vars on the fly\n\n            store.powershell().await.unwrap_or_else(|e| {\n                format!(\"echo 'Atuin: failed to read and generate vars: \\n{r}\\n{e}'\",)\n            })\n        }\n    }\n}\n\n/// Return powershell dotfile config\n///\n/// Do not return an error. We should not prevent the shell from starting.\n///\n/// In the worst case, Atuin should not function but the shell should start correctly.\n///\n/// While currently this only returns aliases, it will be extended to also return other synced dotfiles\npub async fn alias_config(store: &AliasStore) -> String {\n    // First try to read the cached config\n    let aliases = atuin_common::utils::dotfiles_cache_dir().join(\"aliases.ps1\");\n\n    if aliases.exists() {\n        return cached_aliases(aliases, store).await;\n    }\n\n    if let Err(e) = store.build().await {\n        return format!(\"echo 'Atuin: failed to generate aliases: {e}'\");\n    }\n\n    cached_aliases(aliases, store).await\n}\n\npub async fn var_config(store: &VarStore) -> String {\n    // First try to read the cached config\n    let vars = atuin_common::utils::dotfiles_cache_dir().join(\"vars.ps1\");\n\n    if vars.exists() {\n        return cached_vars(vars, store).await;\n    }\n\n    if let Err(e) = store.build().await {\n        return format!(\"echo 'Atuin: failed to generate vars: {e}'\");\n    }\n\n    cached_vars(vars, store).await\n}\n\npub fn format_alias(alias: &Alias) -> String {\n    // Set-Alias doesn't support adding implicit arguments, so use a function.\n    // See https://github.com/PowerShell/PowerShell/issues/12962\n\n    let mut result = secure_command(&format!(\n        \"function {} {{\\n    {}{} @args\\n}}\",\n        alias.name,\n        if alias.value.starts_with(['\"', '\\'']) {\n            \"& \"\n        } else {\n            \"\"\n        },\n        alias.value\n    ));\n\n    // This makes the file layout prettier\n    result.insert(0, '\\n');\n    result\n}\n\npub fn format_var(var: &Var) -> String {\n    secure_command(&format!(\n        \"${}{} = '{}'\",\n        if var.export { \"env:\" } else { \"\" },\n        var.name,\n        var.value.replace(\"'\", \"''\")\n    ))\n}\n\n/// Wraps the given command in an Invoke-Expression to ensure the outer script is not halted\n/// if the inner command contains a syntax error.\nfn secure_command(command: &str) -> String {\n    format!(\n        \"Invoke-Expression -ErrorAction Continue -Command '{}'\\n\",\n        command.replace(\"'\", \"''\")\n    )\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn aliases() {\n        assert_eq!(\n            format_alias(&Alias {\n                name: \"gp\".to_string(),\n                value: \"git push\".to_string(),\n            }),\n            \"\\n\".to_string()\n                + &secure_command(\n                    \"function gp {\n    git push @args\n}\"\n                )\n        );\n\n        assert_eq!(\n            format_alias(&Alias {\n                name: \"spc\".to_string(),\n                value: \"\\\"path with spaces\\\" arg\".to_string(),\n            }),\n            \"\\n\".to_string()\n                + &secure_command(\n                    \"function spc {\n    & \\\"path with spaces\\\" arg @args\n}\"\n                )\n        );\n    }\n\n    #[test]\n    fn vars() {\n        assert_eq!(\n            format_var(&Var {\n                name: \"FOO\".to_owned(),\n                value: \"bar 'baz'\".to_owned(),\n                export: true,\n            }),\n            secure_command(\"$env:FOO = 'bar ''baz'''\")\n        );\n\n        assert_eq!(\n            format_var(&Var {\n                name: \"TEST\".to_owned(),\n                value: \"1\".to_owned(),\n                export: false,\n            }),\n            secure_command(\"$TEST = '1'\")\n        );\n    }\n\n    #[test]\n    fn invoke_expression() {\n        assert_eq!(\n            secure_command(\"echo 'foo'\"),\n            \"Invoke-Expression -ErrorAction Continue -Command 'echo ''foo'''\\n\"\n        )\n    }\n}\n"
  },
  {
    "path": "crates/atuin-dotfiles/src/shell/xonsh.rs",
    "content": "use std::path::PathBuf;\n\nuse crate::store::{AliasStore, var::VarStore};\n\nasync fn cached_aliases(path: PathBuf, store: &AliasStore) -> String {\n    match tokio::fs::read_to_string(path).await {\n        Ok(aliases) => aliases,\n        Err(r) => {\n            // we failed to read the file for some reason, but the file does exist\n            // fallback to generating new aliases on the fly\n\n            store.xonsh().await.unwrap_or_else(|e| {\n                format!(\"echo 'Atuin: failed to read and generate aliases: \\n{r}\\n{e}'\",)\n            })\n        }\n    }\n}\n\nasync fn cached_vars(path: PathBuf, store: &VarStore) -> String {\n    match tokio::fs::read_to_string(path).await {\n        Ok(vars) => vars,\n        Err(r) => {\n            // we failed to read the file for some reason, but the file does exist\n            // fallback to generating new vars on the fly\n\n            store.xonsh().await.unwrap_or_else(|e| {\n                format!(\"echo 'Atuin: failed to read and generate vars: \\n{r}\\n{e}'\",)\n            })\n        }\n    }\n}\n\n/// Return xonsh dotfile config\n///\n/// Do not return an error. We should not prevent the shell from starting.\n///\n/// In the worst case, Atuin should not function but the shell should start correctly.\n///\n/// While currently this only returns aliases, it will be extended to also return other synced dotfiles\npub async fn alias_config(store: &AliasStore) -> String {\n    // First try to read the cached config\n    let aliases = atuin_common::utils::dotfiles_cache_dir().join(\"aliases.xsh\");\n\n    if aliases.exists() {\n        return cached_aliases(aliases, store).await;\n    }\n\n    if let Err(e) = store.build().await {\n        return format!(\"echo 'Atuin: failed to generate aliases: {e}'\");\n    }\n\n    cached_aliases(aliases, store).await\n}\n\npub async fn var_config(store: &VarStore) -> String {\n    // First try to read the cached config\n    let vars = atuin_common::utils::dotfiles_cache_dir().join(\"vars.xsh\");\n\n    if vars.exists() {\n        return cached_vars(vars, store).await;\n    }\n\n    if let Err(e) = store.build().await {\n        return format!(\"echo 'Atuin: failed to generate vars: {e}'\");\n    }\n\n    cached_vars(vars, store).await\n}\n"
  },
  {
    "path": "crates/atuin-dotfiles/src/shell/zsh.rs",
    "content": "use std::path::PathBuf;\n\nuse crate::store::{AliasStore, var::VarStore};\n\nasync fn cached_aliases(path: PathBuf, store: &AliasStore) -> String {\n    match tokio::fs::read_to_string(path).await {\n        Ok(aliases) => aliases,\n        Err(r) => {\n            // we failed to read the file for some reason, but the file does exist\n            // fallback to generating new aliases on the fly\n\n            store.posix().await.unwrap_or_else(|e| {\n                format!(\"echo 'Atuin: failed to read and generate aliases: \\n{r}\\n{e}'\",)\n            })\n        }\n    }\n}\n\nasync fn cached_vars(path: PathBuf, store: &VarStore) -> String {\n    match tokio::fs::read_to_string(path).await {\n        Ok(aliases) => aliases,\n        Err(r) => {\n            // we failed to read the file for some reason, but the file does exist\n            // fallback to generating new vars on the fly\n\n            store.posix().await.unwrap_or_else(|e| {\n                format!(\"echo 'Atuin: failed to read and generate aliases: \\n{r}\\n{e}'\",)\n            })\n        }\n    }\n}\n\n/// Return zsh dotfile config\n///\n/// Do not return an error. We should not prevent the shell from starting.\n///\n/// In the worst case, Atuin should not function but the shell should start correctly.\n///\n/// While currently this only returns aliases, it will be extended to also return other synced dotfiles\npub async fn alias_config(store: &AliasStore) -> String {\n    // First try to read the cached config\n    let aliases = atuin_common::utils::dotfiles_cache_dir().join(\"aliases.zsh\");\n\n    if aliases.exists() {\n        return cached_aliases(aliases, store).await;\n    }\n\n    if let Err(e) = store.build().await {\n        return format!(\"echo 'Atuin: failed to generate aliases: {e}'\");\n    }\n\n    cached_aliases(aliases, store).await\n}\n\npub async fn var_config(store: &VarStore) -> String {\n    // First try to read the cached config\n    let vars = atuin_common::utils::dotfiles_cache_dir().join(\"vars.zsh\");\n\n    if vars.exists() {\n        return cached_vars(vars, store).await;\n    }\n\n    if let Err(e) = store.build().await {\n        return format!(\"echo 'Atuin: failed to generate aliases: {e}'\");\n    }\n\n    cached_vars(vars, store).await\n}\n"
  },
  {
    "path": "crates/atuin-dotfiles/src/shell.rs",
    "content": "use eyre::{Result, ensure, eyre};\nuse rmp::{decode, encode};\nuse serde::Serialize;\n\nuse atuin_common::shell::{Shell, ShellError};\n\nuse crate::store::AliasStore;\n\npub mod bash;\npub mod fish;\npub mod powershell;\npub mod xonsh;\npub mod zsh;\n\n#[derive(Debug, Clone, PartialEq, Eq, Serialize)]\npub struct Alias {\n    pub name: String,\n    pub value: String,\n}\n\n#[derive(Debug, Clone, PartialEq, Eq, Serialize)]\npub struct Var {\n    pub name: String,\n    pub value: String,\n\n    // False? This is a _shell var_\n    // True? This is an _env var_\n    pub export: bool,\n}\n\nimpl Var {\n    /// Serialize into the given vec\n    /// This is intended to be called by the store\n    pub fn serialize(&self, output: &mut Vec<u8>) -> Result<()> {\n        encode::write_array_len(output, 3)?; // 3 fields\n\n        encode::write_str(output, self.name.as_str())?;\n        encode::write_str(output, self.value.as_str())?;\n        encode::write_bool(output, self.export)?;\n\n        Ok(())\n    }\n\n    pub fn deserialize(bytes: &mut decode::Bytes) -> Result<Self> {\n        fn error_report<E: std::fmt::Debug>(err: E) -> eyre::Report {\n            eyre!(\"{err:?}\")\n        }\n\n        let nfields = decode::read_array_len(bytes).map_err(error_report)?;\n\n        ensure!(\n            nfields == 3,\n            \"too many entries in v0 dotfiles env create record, got {}, expected {}\",\n            nfields,\n            3\n        );\n\n        let bytes = bytes.remaining_slice();\n\n        let (key, bytes) = decode::read_str_from_slice(bytes).map_err(error_report)?;\n        let (value, bytes) = decode::read_str_from_slice(bytes).map_err(error_report)?;\n\n        let mut bytes = decode::Bytes::new(bytes);\n        let export = decode::read_bool(&mut bytes).map_err(error_report)?;\n\n        ensure!(\n            bytes.remaining_slice().is_empty(),\n            \"trailing bytes in encoded dotfiles env record, malformed\"\n        );\n\n        Ok(Var {\n            name: key.to_owned(),\n            value: value.to_owned(),\n            export,\n        })\n    }\n}\n\npub fn parse_alias(line: &str) -> Option<Alias> {\n    // consider the fact we might be importing a fish alias\n    // 'alias' output\n    // fish: alias foo bar\n    // posix: foo=bar\n\n    let is_fish = line.split(' ').next().unwrap_or(\"\") == \"alias\";\n\n    let parts: Vec<&str> = if is_fish {\n        line.split(' ')\n            .enumerate()\n            .filter_map(|(n, i)| if n == 0 { None } else { Some(i) })\n            .collect()\n    } else {\n        line.split('=').collect()\n    };\n\n    if parts.len() <= 1 {\n        return None;\n    }\n\n    let mut parts = parts.iter().map(|s| s.to_string());\n\n    let name = parts.next().unwrap();\n\n    let remaining = if is_fish {\n        parts.collect::<Vec<String>>().join(\" \")\n    } else {\n        parts.collect::<Vec<String>>().join(\"=\")\n    };\n\n    Some(Alias {\n        name,\n        value: remaining.trim().to_string(),\n    })\n}\n\npub fn existing_aliases(shell: Option<Shell>) -> Result<Vec<Alias>, ShellError> {\n    let shell = if let Some(shell) = shell {\n        shell\n    } else {\n        Shell::current()\n    };\n\n    // this only supports posix-y shells atm\n    if !shell.is_posixish() {\n        return Err(ShellError::NotSupported);\n    }\n\n    // This will return a list of aliases, each on its own line\n    // They will be in the form foo=bar\n    let aliases = shell.run_interactive([\"alias\"])?;\n\n    let aliases: Vec<Alias> = aliases.lines().filter_map(parse_alias).collect();\n\n    Ok(aliases)\n}\n\n/// Import aliases from the current shell\n/// This will not import aliases already in the store\n/// Returns aliases that were set\npub async fn import_aliases(store: &AliasStore) -> Result<Vec<Alias>> {\n    let shell_aliases = existing_aliases(None)?;\n    let store_aliases = store.aliases().await?;\n\n    let mut res = Vec::new();\n\n    for alias in shell_aliases {\n        // O(n), but n is small, and imports infrequent\n        // can always make a map\n        if store_aliases.contains(&alias) {\n            continue;\n        }\n\n        res.push(alias.clone());\n        store.set(&alias.name, &alias.value).await?;\n    }\n\n    Ok(res)\n}\n\n#[cfg(test)]\nmod tests {\n    use crate::shell::{Alias, parse_alias};\n\n    #[test]\n    fn test_parse_simple_alias() {\n        let alias = super::parse_alias(\"foo=bar\").expect(\"failed to parse alias\");\n        assert_eq!(alias.name, \"foo\");\n        assert_eq!(alias.value, \"bar\");\n    }\n\n    #[test]\n    fn test_parse_quoted_alias() {\n        let alias = super::parse_alias(\"emacs='TERM=xterm-24bits emacs -nw'\")\n            .expect(\"failed to parse alias\");\n\n        assert_eq!(alias.name, \"emacs\");\n        assert_eq!(alias.value, \"'TERM=xterm-24bits emacs -nw'\");\n\n        let git_alias = super::parse_alias(\"gwip='git add -A; git rm $(git ls-files --deleted) 2> /dev/null; git commit --no-verify --no-gpg-sign --message \\\"--wip-- [skip ci]\\\"'\").expect(\"failed to parse alias\");\n        assert_eq!(git_alias.name, \"gwip\");\n        assert_eq!(\n            git_alias.value,\n            \"'git add -A; git rm $(git ls-files --deleted) 2> /dev/null; git commit --no-verify --no-gpg-sign --message \\\"--wip-- [skip ci]\\\"'\"\n        );\n    }\n\n    #[test]\n    fn test_parse_quoted_alias_equals() {\n        let alias = super::parse_alias(\"emacs='TERM=xterm-24bits emacs -nw --foo=bar'\")\n            .expect(\"failed to parse alias\");\n        assert_eq!(alias.name, \"emacs\");\n        assert_eq!(alias.value, \"'TERM=xterm-24bits emacs -nw --foo=bar'\");\n    }\n\n    #[test]\n    fn test_parse_fish() {\n        let alias = super::parse_alias(\"alias foo bar\").expect(\"failed to parse alias\");\n        assert_eq!(alias.name, \"foo\");\n        assert_eq!(alias.value, \"bar\");\n\n        let alias =\n            super::parse_alias(\"alias x 'exa --icons --git --classify --group-directories-first'\")\n                .expect(\"failed to parse alias\");\n\n        assert_eq!(alias.name, \"x\");\n        assert_eq!(\n            alias.value,\n            \"'exa --icons --git --classify --group-directories-first'\"\n        );\n    }\n\n    #[test]\n    fn test_parse_with_fortune() {\n        // Because we run the alias command in an interactive subshell\n        // there may be other output.\n        // Ensure that the parser can handle it\n        // Annoyingly not all aliases are picked up all the time if we use\n        // a non-interactive subshell. Boo.\n        let shell = \"\n/ In a consumer society there are     \\\\\n| inevitably two kinds of slaves: the |\n| prisoners of addiction and the      |\n\\\\ prisoners of envy.                  /\n -------------------------------------\n        \\\\   ^__^\n         \\\\  (oo)\\\\_______\n            (__)\\\\       )\\\\/\\\\\n                ||----w |\n                ||     ||\nemacs='TERM=xterm-24bits emacs -nw --foo=bar'\nk=kubectl\n\";\n\n        let aliases: Vec<Alias> = shell.lines().filter_map(parse_alias).collect();\n        assert_eq!(aliases[0].name, \"emacs\");\n        assert_eq!(aliases[0].value, \"'TERM=xterm-24bits emacs -nw --foo=bar'\");\n\n        assert_eq!(aliases[1].name, \"k\");\n        assert_eq!(aliases[1].value, \"kubectl\");\n    }\n}\n"
  },
  {
    "path": "crates/atuin-dotfiles/src/store/alias.rs",
    "content": "\n"
  },
  {
    "path": "crates/atuin-dotfiles/src/store/var.rs",
    "content": "/// Store for shell vars\n/// I should abstract this and reuse code between the alias/env stores\n/// This is easier for now\n/// Once I have two implementations, building a common base is much easier.\nuse std::collections::BTreeMap;\n\nuse atuin_client::record::sqlite_store::SqliteStore;\nuse atuin_common::record::{DecryptedData, Host, HostId};\nuse eyre::{Result, bail, ensure, eyre};\n\nuse atuin_client::record::encryption::PASETO_V4;\nuse atuin_client::record::store::Store;\n\nuse crate::shell::Var;\n\nconst DOTFILES_VAR_VERSION: &str = \"v0\";\nconst DOTFILES_VAR_TAG: &str = \"dotfiles-var\";\nconst DOTFILES_VAR_LEN: usize = 20000; // 20kb max total len, way more than should be needed.\n\n#[derive(Debug, Clone, PartialEq, Eq)]\npub enum VarRecord {\n    Create(Var),    // create a full record\n    Delete(String), // delete by name\n}\n\nimpl VarRecord {\n    pub fn serialize(&self) -> Result<DecryptedData> {\n        use rmp::encode;\n\n        let mut output = vec![];\n\n        match self {\n            VarRecord::Create(env) => {\n                encode::write_u8(&mut output, 0)?; // create\n\n                env.serialize(&mut output)?;\n            }\n            VarRecord::Delete(env) => {\n                encode::write_u8(&mut output, 1)?; // delete\n                encode::write_array_len(&mut output, 1)?; // 1 field\n\n                encode::write_str(&mut output, env.as_str())?;\n            }\n        }\n\n        Ok(DecryptedData(output))\n    }\n\n    pub fn deserialize(data: &DecryptedData, version: &str) -> Result<Self> {\n        use rmp::decode;\n\n        fn error_report<E: std::fmt::Debug>(err: E) -> eyre::Report {\n            eyre!(\"{err:?}\")\n        }\n\n        match version {\n            DOTFILES_VAR_VERSION => {\n                let mut bytes = decode::Bytes::new(&data.0);\n\n                let record_type = decode::read_u8(&mut bytes).map_err(error_report)?;\n\n                match record_type {\n                    // create\n                    0 => {\n                        let env = Var::deserialize(&mut bytes)?;\n                        Ok(VarRecord::Create(env))\n                    }\n\n                    // delete\n                    1 => {\n                        let nfields = decode::read_array_len(&mut bytes).map_err(error_report)?;\n                        ensure!(\n                            nfields == 1,\n                            \"too many entries in v0 dotfiles var delete record\"\n                        );\n\n                        let bytes = bytes.remaining_slice();\n\n                        let (key, bytes) =\n                            decode::read_str_from_slice(bytes).map_err(error_report)?;\n\n                        if !bytes.is_empty() {\n                            bail!(\"trailing bytes in encoded dotfiles var record. malformed\")\n                        }\n\n                        Ok(VarRecord::Delete(key.to_owned()))\n                    }\n\n                    n => {\n                        bail!(\"unknown Dotfiles var record type {n}\")\n                    }\n                }\n            }\n            _ => {\n                bail!(\"unknown version {version:?}\")\n            }\n        }\n    }\n}\n\n#[derive(Debug, Clone)]\npub struct VarStore {\n    pub store: SqliteStore,\n    pub host_id: HostId,\n    pub encryption_key: [u8; 32],\n}\n\nimpl VarStore {\n    // will want to init the actual kv store when that is done\n    pub fn new(store: SqliteStore, host_id: HostId, encryption_key: [u8; 32]) -> VarStore {\n        VarStore {\n            store,\n            host_id,\n            encryption_key,\n        }\n    }\n\n    /// Escape a value for use in POSIX shells (bash, zsh)\n    /// This adds double quotes around the value and escapes any embedded double quotes\n    fn escape_posix_value(value: &str) -> String {\n        // If the value contains no special characters, we can use it unquoted\n        if value\n            .chars()\n            .all(|c| c.is_alphanumeric() || c == '_' || c == '-' || c == '/' || c == '.')\n        {\n            value.to_string()\n        } else {\n            // Otherwise, wrap in double quotes and escape any special characters\n            format!(\n                \"\\\"{}\\\"\",\n                value\n                    .replace('\\\\', \"\\\\\\\\\")\n                    .replace('\"', \"\\\\\\\"\")\n                    .replace('$', \"\\\\$\")\n                    .replace('`', \"\\\\`\")\n            )\n        }\n    }\n\n    /// Escape a value for use in fish shell\n    /// Fish uses single quotes for literal strings, but we need to handle embedded single quotes\n    fn escape_fish_value(value: &str) -> String {\n        // If the value contains no special characters, we can use it unquoted\n        if value\n            .chars()\n            .all(|c| c.is_alphanumeric() || c == '_' || c == '-' || c == '/' || c == '.')\n        {\n            value.to_string()\n        } else {\n            // Use single quotes and escape any embedded single quotes\n            format!(\"'{}'\", value.replace('\\'', \"\\\\'\"))\n        }\n    }\n\n    /// Escape a value for use in xonsh\n    /// Xonsh uses Python-style string literals\n    fn escape_xonsh_value(value: &str) -> String {\n        // If the value contains no special characters, we can use it unquoted\n        if value\n            .chars()\n            .all(|c| c.is_alphanumeric() || c == '_' || c == '-' || c == '/' || c == '.')\n        {\n            value.to_string()\n        } else {\n            // Use double quotes and escape appropriately for Python strings\n            format!(\"\\\"{}\\\"\", value.replace('\\\\', \"\\\\\\\\\").replace('\"', \"\\\\\\\"\"))\n        }\n    }\n\n    pub async fn xonsh(&self) -> Result<String> {\n        let env = self.vars().await?;\n        Ok(Self::format_xonsh(&env))\n    }\n\n    pub async fn fish(&self) -> Result<String> {\n        let env = self.vars().await?;\n        Ok(Self::format_fish(&env))\n    }\n\n    pub async fn posix(&self) -> Result<String> {\n        let env = self.vars().await?;\n        Ok(Self::format_posix(&env))\n    }\n\n    pub async fn powershell(&self) -> Result<String> {\n        let env = self.vars().await?;\n        Ok(Self::format_powershell(&env))\n    }\n\n    fn format_xonsh(env: &[Var]) -> String {\n        let mut config = String::new();\n\n        for env in env {\n            let escaped_value = Self::escape_xonsh_value(&env.value);\n            config.push_str(&format!(\"${}={}\\n\", env.name, escaped_value));\n        }\n\n        config\n    }\n\n    fn format_fish(env: &[Var]) -> String {\n        let mut config = String::new();\n\n        for env in env {\n            let escaped_value = Self::escape_fish_value(&env.value);\n            config.push_str(&format!(\"set -gx {} {}\\n\", env.name, escaped_value));\n        }\n\n        config\n    }\n\n    fn format_posix(env: &[Var]) -> String {\n        let mut config = String::new();\n\n        for env in env {\n            let escaped_value = Self::escape_posix_value(&env.value);\n            if env.export {\n                config.push_str(&format!(\"export {}={}\\n\", env.name, escaped_value));\n            } else {\n                config.push_str(&format!(\"{}={}\\n\", env.name, escaped_value));\n            }\n        }\n\n        config\n    }\n\n    fn format_powershell(env: &[Var]) -> String {\n        let mut config = String::new();\n\n        for var in env {\n            config.push_str(&crate::shell::powershell::format_var(var));\n        }\n\n        config\n    }\n\n    pub async fn build(&self) -> Result<()> {\n        let dir = atuin_common::utils::dotfiles_cache_dir();\n        tokio::fs::create_dir_all(dir.clone()).await?;\n\n        let env = self.vars().await?;\n\n        // Build for all supported shells\n        let posix = Self::format_posix(&env);\n        let xonsh = Self::format_xonsh(&env);\n        let fsh = Self::format_fish(&env);\n        let powershell = Self::format_powershell(&env);\n\n        // All the same contents, maybe optimize in the future or perhaps there will be quirks\n        // per-shell\n        // I'd prefer separation atm\n        let zsh = dir.join(\"vars.zsh\");\n        let bash = dir.join(\"vars.bash\");\n        let fish = dir.join(\"vars.fish\");\n        let xsh = dir.join(\"vars.xsh\");\n        let ps1 = dir.join(\"vars.ps1\");\n\n        tokio::fs::write(zsh, &posix).await?;\n        tokio::fs::write(bash, &posix).await?;\n        tokio::fs::write(fish, &fsh).await?;\n        tokio::fs::write(xsh, &xonsh).await?;\n        tokio::fs::write(ps1, &powershell).await?;\n\n        Ok(())\n    }\n\n    pub async fn set(&self, name: &str, value: &str, export: bool) -> Result<()> {\n        if name.len() + value.len() > DOTFILES_VAR_LEN {\n            return Err(eyre!(\n                \"var record too large: max len {} bytes\",\n                DOTFILES_VAR_LEN\n            ));\n        }\n\n        let record = VarRecord::Create(Var {\n            name: name.to_string(),\n            value: value.to_string(),\n            export,\n        });\n\n        let bytes = record.serialize()?;\n\n        let idx = self\n            .store\n            .last(self.host_id, DOTFILES_VAR_TAG)\n            .await?\n            .map_or(0, |entry| entry.idx + 1);\n\n        let record = atuin_common::record::Record::builder()\n            .host(Host::new(self.host_id))\n            .version(DOTFILES_VAR_VERSION.to_string())\n            .tag(DOTFILES_VAR_TAG.to_string())\n            .idx(idx)\n            .data(bytes)\n            .build();\n\n        self.store\n            .push(&record.encrypt::<PASETO_V4>(&self.encryption_key))\n            .await?;\n\n        // set mutates shell config, so build again\n        self.build().await?;\n\n        Ok(())\n    }\n\n    pub async fn delete(&self, name: &str) -> Result<()> {\n        if name.len() > DOTFILES_VAR_LEN {\n            return Err(eyre!(\n                \"var record too large: max len {} bytes\",\n                DOTFILES_VAR_LEN,\n            ));\n        }\n\n        let record = VarRecord::Delete(name.to_string());\n\n        let bytes = record.serialize()?;\n\n        let idx = self\n            .store\n            .last(self.host_id, DOTFILES_VAR_TAG)\n            .await?\n            .map_or(0, |entry| entry.idx + 1);\n\n        let record = atuin_common::record::Record::builder()\n            .host(Host::new(self.host_id))\n            .version(DOTFILES_VAR_VERSION.to_string())\n            .tag(DOTFILES_VAR_TAG.to_string())\n            .idx(idx)\n            .data(bytes)\n            .build();\n\n        self.store\n            .push(&record.encrypt::<PASETO_V4>(&self.encryption_key))\n            .await?;\n\n        // delete mutates shell config, so build again\n        self.build().await?;\n\n        Ok(())\n    }\n\n    pub async fn vars(&self) -> Result<Vec<Var>> {\n        let mut build = BTreeMap::new();\n\n        // this is sorted, oldest to newest\n        let tagged = self.store.all_tagged(DOTFILES_VAR_TAG).await?;\n\n        for record in tagged {\n            let version = record.version.clone();\n\n            let decrypted = match version.as_str() {\n                DOTFILES_VAR_VERSION => record.decrypt::<PASETO_V4>(&self.encryption_key)?,\n                version => bail!(\"unknown version {version:?}\"),\n            };\n\n            let ar = VarRecord::deserialize(&decrypted.data, version.as_str())?;\n\n            match ar {\n                VarRecord::Create(a) => {\n                    build.insert(a.name.clone(), a);\n                }\n                VarRecord::Delete(d) => {\n                    build.remove(&d);\n                }\n            }\n        }\n\n        Ok(build.into_values().collect())\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use rand::rngs::OsRng;\n\n    use atuin_client::record::sqlite_store::SqliteStore;\n\n    use crate::{shell::Var, store::test_local_timeout};\n\n    use super::{DOTFILES_VAR_VERSION, VarRecord, VarStore};\n    use crypto_secretbox::{KeyInit, XSalsa20Poly1305};\n\n    #[test]\n    fn encode_decode() {\n        let record = Var {\n            name: \"BEEP\".to_owned(),\n            value: \"boop\".to_owned(),\n            export: false,\n        };\n        let record = VarRecord::Create(record);\n\n        let snapshot = [\n            204, 0, 147, 164, 66, 69, 69, 80, 164, 98, 111, 111, 112, 194,\n        ];\n\n        let encoded = record.serialize().unwrap();\n        let decoded = VarRecord::deserialize(&encoded, DOTFILES_VAR_VERSION).unwrap();\n\n        assert_eq!(encoded.0, &snapshot);\n        assert_eq!(decoded, record);\n    }\n\n    #[test]\n    fn test_escape_posix_value() {\n        // Simple values should not be quoted\n        assert_eq!(VarStore::escape_posix_value(\"simple\"), \"simple\");\n        assert_eq!(VarStore::escape_posix_value(\"path/to/file\"), \"path/to/file\");\n        assert_eq!(\n            VarStore::escape_posix_value(\"value_with_underscores\"),\n            \"value_with_underscores\"\n        );\n\n        // Values with spaces should be quoted\n        assert_eq!(\n            VarStore::escape_posix_value(\"hello world\"),\n            \"\\\"hello world\\\"\"\n        );\n        assert_eq!(VarStore::escape_posix_value(\"bar baz\"), \"\\\"bar baz\\\"\");\n\n        // Values with special characters should be quoted and escaped\n        assert_eq!(\n            VarStore::escape_posix_value(\"say \\\"hello\\\"\"),\n            \"\\\"say \\\\\\\"hello\\\\\\\"\\\"\"\n        );\n        assert_eq!(\n            VarStore::escape_posix_value(\"path\\\\with\\\\backslashes\"),\n            \"\\\"path\\\\\\\\with\\\\\\\\backslashes\\\"\"\n        );\n        assert_eq!(\n            VarStore::escape_posix_value(\"say $hello\"),\n            \"\\\"say \\\\$hello\\\"\"\n        );\n        assert_eq!(\n            VarStore::escape_posix_value(\"see `example.md`\"),\n            \"\\\"see \\\\`example.md\\\\`\\\"\"\n        );\n    }\n\n    #[test]\n    fn test_escape_fish_value() {\n        // Simple values should not be quoted\n        assert_eq!(VarStore::escape_fish_value(\"simple\"), \"simple\");\n        assert_eq!(VarStore::escape_fish_value(\"path/to/file\"), \"path/to/file\");\n\n        // Values with spaces should be single-quoted\n        assert_eq!(VarStore::escape_fish_value(\"hello world\"), \"'hello world'\");\n        assert_eq!(VarStore::escape_fish_value(\"bar baz\"), \"'bar baz'\");\n\n        // Values with single quotes should be escaped\n        assert_eq!(VarStore::escape_fish_value(\"don't\"), \"'don\\\\'t'\");\n    }\n\n    #[test]\n    fn test_escape_xonsh_value() {\n        // Simple values should not be quoted\n        assert_eq!(VarStore::escape_xonsh_value(\"simple\"), \"simple\");\n        assert_eq!(VarStore::escape_xonsh_value(\"path/to/file\"), \"path/to/file\");\n\n        // Values with spaces should be quoted\n        assert_eq!(\n            VarStore::escape_xonsh_value(\"hello world\"),\n            \"\\\"hello world\\\"\"\n        );\n        assert_eq!(VarStore::escape_xonsh_value(\"bar baz\"), \"\\\"bar baz\\\"\");\n\n        // Values with special characters should be quoted and escaped\n        assert_eq!(\n            VarStore::escape_xonsh_value(\"say \\\"hello\\\"\"),\n            \"\\\"say \\\\\\\"hello\\\\\\\"\\\"\"\n        );\n        assert_eq!(\n            VarStore::escape_xonsh_value(\"path\\\\with\\\\backslashes\"),\n            \"\\\"path\\\\\\\\with\\\\\\\\backslashes\\\"\"\n        );\n    }\n\n    #[tokio::test]\n    async fn build_vars() {\n        let store = SqliteStore::new(\":memory:\", test_local_timeout())\n            .await\n            .unwrap();\n        let key: [u8; 32] = XSalsa20Poly1305::generate_key(&mut OsRng).into();\n        let host_id = atuin_common::record::HostId(atuin_common::utils::uuid_v7());\n\n        let env = VarStore::new(store, host_id, key);\n\n        env.set(\"BEEP\", \"boop\", false).await.unwrap();\n        env.set(\"HOMEBREW_NO_AUTO_UPDATE\", \"1\", true).await.unwrap();\n\n        let mut env_vars = env.vars().await.unwrap();\n\n        env_vars.sort_by_key(|a| a.name.clone());\n\n        assert_eq!(env_vars.len(), 2);\n\n        assert_eq!(\n            env_vars[0],\n            Var {\n                name: String::from(\"BEEP\"),\n                value: String::from(\"boop\"),\n                export: false,\n            }\n        );\n\n        assert_eq!(\n            env_vars[1],\n            Var {\n                name: String::from(\"HOMEBREW_NO_AUTO_UPDATE\"),\n                value: String::from(\"1\"),\n                export: true,\n            }\n        );\n    }\n\n    #[tokio::test]\n    async fn test_var_generation_with_spaces() {\n        let store = SqliteStore::new(\":memory:\", test_local_timeout())\n            .await\n            .unwrap();\n        let key: [u8; 32] = XSalsa20Poly1305::generate_key(&mut OsRng).into();\n        let host_id = atuin_common::record::HostId(atuin_common::utils::uuid_v7());\n\n        let env = VarStore::new(store, host_id, key);\n\n        // Test the exact scenario from the bug report\n        env.set(\"FOO\", \"bar baz\", true).await.unwrap();\n\n        let posix_output = env.posix().await.unwrap();\n        let fish_output = env.fish().await.unwrap();\n        let xonsh_output = env.xonsh().await.unwrap();\n\n        // POSIX should quote the value\n        assert_eq!(posix_output, \"export FOO=\\\"bar baz\\\"\\n\");\n\n        // Fish should quote the value\n        assert_eq!(fish_output, \"set -gx FOO 'bar baz'\\n\");\n\n        // Xonsh should quote the value\n        assert_eq!(xonsh_output, \"$FOO=\\\"bar baz\\\"\\n\");\n    }\n}\n"
  },
  {
    "path": "crates/atuin-dotfiles/src/store.rs",
    "content": "use std::collections::BTreeMap;\n\nuse atuin_client::record::sqlite_store::SqliteStore;\n// Sync aliases\n// This will be noticeable similar to the kv store, though I expect the two shall diverge\n// While we will support a range of shell config, I'd rather have a larger number of small records\n// + stores, rather than one mega config store.\nuse atuin_common::record::{DecryptedData, Host, HostId};\nuse atuin_common::utils::unquote;\nuse eyre::{Result, bail, ensure, eyre};\n\nuse atuin_client::record::encryption::PASETO_V4;\nuse atuin_client::record::store::Store;\n\nuse crate::shell::Alias;\n\nconst CONFIG_SHELL_ALIAS_VERSION: &str = \"v0\";\nconst CONFIG_SHELL_ALIAS_TAG: &str = \"config-shell-alias\";\nconst CONFIG_SHELL_ALIAS_FIELD_MAX_LEN: usize = 20000; // 20kb max total len, way more than should be needed.\n\nmod alias;\npub mod var;\n\n#[derive(Debug, Clone, PartialEq, Eq)]\npub enum AliasRecord {\n    Create(Alias),  // create a full record\n    Delete(String), // delete by name\n}\n\nimpl AliasRecord {\n    pub fn serialize(&self) -> Result<DecryptedData> {\n        use rmp::encode;\n\n        let mut output = vec![];\n\n        match self {\n            AliasRecord::Create(alias) => {\n                encode::write_u8(&mut output, 0)?; // create\n                encode::write_array_len(&mut output, 2)?; // 2 fields\n\n                encode::write_str(&mut output, alias.name.as_str())?;\n                encode::write_str(&mut output, alias.value.as_str())?;\n            }\n            AliasRecord::Delete(name) => {\n                encode::write_u8(&mut output, 1)?; // delete\n                encode::write_array_len(&mut output, 1)?; // 1 field\n\n                encode::write_str(&mut output, name.as_str())?;\n            }\n        }\n\n        Ok(DecryptedData(output))\n    }\n\n    pub fn deserialize(data: &DecryptedData, version: &str) -> Result<Self> {\n        use rmp::decode;\n\n        fn error_report<E: std::fmt::Debug>(err: E) -> eyre::Report {\n            eyre!(\"{err:?}\")\n        }\n\n        match version {\n            CONFIG_SHELL_ALIAS_VERSION => {\n                let mut bytes = decode::Bytes::new(&data.0);\n\n                let record_type = decode::read_u8(&mut bytes).map_err(error_report)?;\n\n                match record_type {\n                    // create\n                    0 => {\n                        let nfields = decode::read_array_len(&mut bytes).map_err(error_report)?;\n                        ensure!(\n                            nfields == 2,\n                            \"too many entries in v0 shell alias create record\"\n                        );\n\n                        let bytes = bytes.remaining_slice();\n\n                        let (key, bytes) =\n                            decode::read_str_from_slice(bytes).map_err(error_report)?;\n                        let (value, bytes) =\n                            decode::read_str_from_slice(bytes).map_err(error_report)?;\n\n                        if !bytes.is_empty() {\n                            bail!(\"trailing bytes in encoded shell alias record. malformed\")\n                        }\n\n                        Ok(AliasRecord::Create(Alias {\n                            name: key.to_owned(),\n                            value: value.to_owned(),\n                        }))\n                    }\n\n                    // delete\n                    1 => {\n                        let nfields = decode::read_array_len(&mut bytes).map_err(error_report)?;\n                        ensure!(\n                            nfields == 1,\n                            \"too many entries in v0 shell alias delete record\"\n                        );\n\n                        let bytes = bytes.remaining_slice();\n\n                        let (key, bytes) =\n                            decode::read_str_from_slice(bytes).map_err(error_report)?;\n\n                        if !bytes.is_empty() {\n                            bail!(\"trailing bytes in encoded shell alias record. malformed\")\n                        }\n\n                        Ok(AliasRecord::Delete(key.to_owned()))\n                    }\n\n                    n => {\n                        bail!(\"unknown AliasRecord type {n}\")\n                    }\n                }\n            }\n            _ => {\n                bail!(\"unknown version {version:?}\")\n            }\n        }\n    }\n}\n\n#[derive(Debug, Clone)]\npub struct AliasStore {\n    pub store: SqliteStore,\n    pub host_id: HostId,\n    pub encryption_key: [u8; 32],\n}\n\nimpl AliasStore {\n    // will want to init the actual kv store when that is done\n    pub fn new(store: SqliteStore, host_id: HostId, encryption_key: [u8; 32]) -> AliasStore {\n        AliasStore {\n            store,\n            host_id,\n            encryption_key,\n        }\n    }\n\n    pub async fn posix(&self) -> Result<String> {\n        let aliases = self.aliases().await?;\n        Ok(Self::format_posix(&aliases))\n    }\n\n    pub async fn xonsh(&self) -> Result<String> {\n        let aliases = self.aliases().await?;\n        Ok(Self::format_xonsh(&aliases))\n    }\n\n    pub async fn powershell(&self) -> Result<String> {\n        let aliases = self.aliases().await?;\n        Ok(Self::format_powershell(&aliases))\n    }\n\n    fn format_posix(aliases: &[Alias]) -> String {\n        let mut config = String::new();\n\n        for alias in aliases {\n            // If it's quoted, remove the quotes. If it's not quoted, do nothing.\n            let value = unquote(alias.value.as_str()).unwrap_or(alias.value.clone());\n\n            // we're about to quote it ourselves anyway!\n            config.push_str(&format!(\"alias {}='{}'\\n\", alias.name, value));\n        }\n\n        config\n    }\n\n    fn format_xonsh(aliases: &[Alias]) -> String {\n        let mut config = String::new();\n\n        for alias in aliases {\n            config.push_str(&format!(\"aliases['{}'] ='{}'\\n\", alias.name, alias.value));\n        }\n\n        config\n    }\n\n    fn format_powershell(aliases: &[Alias]) -> String {\n        let mut config = String::new();\n\n        for alias in aliases {\n            config.push_str(&crate::shell::powershell::format_alias(alias));\n        }\n\n        config\n    }\n\n    pub async fn build(&self) -> Result<()> {\n        let dir = atuin_common::utils::dotfiles_cache_dir();\n        tokio::fs::create_dir_all(dir.clone()).await?;\n\n        let aliases = self.aliases().await?;\n\n        // Build for all supported shells\n        let posix = Self::format_posix(&aliases);\n        let xonsh = Self::format_xonsh(&aliases);\n        let powershell = Self::format_powershell(&aliases);\n\n        // All the same contents, maybe optimize in the future or perhaps there will be quirks\n        // per-shell\n        // I'd prefer separation atm\n        let zsh = dir.join(\"aliases.zsh\");\n        let bash = dir.join(\"aliases.bash\");\n        let fish = dir.join(\"aliases.fish\");\n        let xsh = dir.join(\"aliases.xsh\");\n        let ps1 = dir.join(\"aliases.ps1\");\n\n        tokio::fs::write(zsh, &posix).await?;\n        tokio::fs::write(bash, &posix).await?;\n        tokio::fs::write(fish, &posix).await?;\n        tokio::fs::write(xsh, &xonsh).await?;\n        tokio::fs::write(ps1, &powershell).await?;\n\n        Ok(())\n    }\n\n    pub async fn set(&self, name: &str, value: &str) -> Result<()> {\n        if name.len() + value.len() > CONFIG_SHELL_ALIAS_FIELD_MAX_LEN {\n            return Err(eyre!(\n                \"alias record too large: max len {} bytes\",\n                CONFIG_SHELL_ALIAS_FIELD_MAX_LEN\n            ));\n        }\n\n        let record = AliasRecord::Create(Alias {\n            name: name.to_string(),\n            value: value.to_string(),\n        });\n\n        let bytes = record.serialize()?;\n\n        let idx = self\n            .store\n            .last(self.host_id, CONFIG_SHELL_ALIAS_TAG)\n            .await?\n            .map_or(0, |entry| entry.idx + 1);\n\n        let record = atuin_common::record::Record::builder()\n            .host(Host::new(self.host_id))\n            .version(CONFIG_SHELL_ALIAS_VERSION.to_string())\n            .tag(CONFIG_SHELL_ALIAS_TAG.to_string())\n            .idx(idx)\n            .data(bytes)\n            .build();\n\n        self.store\n            .push(&record.encrypt::<PASETO_V4>(&self.encryption_key))\n            .await?;\n\n        // set mutates shell config, so build again\n        self.build().await?;\n\n        Ok(())\n    }\n\n    pub async fn delete(&self, name: &str) -> Result<()> {\n        if name.len() > CONFIG_SHELL_ALIAS_FIELD_MAX_LEN {\n            return Err(eyre!(\n                \"alias record too large: max len {} bytes\",\n                CONFIG_SHELL_ALIAS_FIELD_MAX_LEN\n            ));\n        }\n\n        let record = AliasRecord::Delete(name.to_string());\n\n        let bytes = record.serialize()?;\n\n        let idx = self\n            .store\n            .last(self.host_id, CONFIG_SHELL_ALIAS_TAG)\n            .await?\n            .map_or(0, |entry| entry.idx + 1);\n\n        let record = atuin_common::record::Record::builder()\n            .host(Host::new(self.host_id))\n            .version(CONFIG_SHELL_ALIAS_VERSION.to_string())\n            .tag(CONFIG_SHELL_ALIAS_TAG.to_string())\n            .idx(idx)\n            .data(bytes)\n            .build();\n\n        self.store\n            .push(&record.encrypt::<PASETO_V4>(&self.encryption_key))\n            .await?;\n\n        // delete mutates shell config, so build again\n        self.build().await?;\n\n        Ok(())\n    }\n\n    pub async fn aliases(&self) -> Result<Vec<Alias>> {\n        let mut build = BTreeMap::new();\n\n        // this is sorted, oldest to newest\n        let tagged = self.store.all_tagged(CONFIG_SHELL_ALIAS_TAG).await?;\n\n        for record in tagged {\n            let version = record.version.clone();\n\n            let decrypted = match version.as_str() {\n                CONFIG_SHELL_ALIAS_VERSION => record.decrypt::<PASETO_V4>(&self.encryption_key)?,\n                version => bail!(\"unknown version {version:?}\"),\n            };\n\n            let ar = AliasRecord::deserialize(&decrypted.data, version.as_str())?;\n\n            match ar {\n                AliasRecord::Create(a) => {\n                    build.insert(a.name.clone(), a);\n                }\n                AliasRecord::Delete(d) => {\n                    build.remove(&d);\n                }\n            }\n        }\n\n        Ok(build.into_values().collect())\n    }\n}\n\n#[cfg(test)]\npub(crate) fn test_local_timeout() -> f64 {\n    std::env::var(\"ATUIN_TEST_LOCAL_TIMEOUT\")\n        .ok()\n        .and_then(|x| x.parse().ok())\n        // this hardcoded value should be replaced by a simple way to get the\n        // default local_timeout of Settings if possible\n        .unwrap_or(2.0)\n}\n\n#[cfg(test)]\nmod tests {\n    use rand::rngs::OsRng;\n\n    use atuin_client::record::sqlite_store::SqliteStore;\n\n    use crate::shell::Alias;\n\n    use super::{AliasRecord, AliasStore, CONFIG_SHELL_ALIAS_VERSION, test_local_timeout};\n    use crypto_secretbox::{KeyInit, XSalsa20Poly1305};\n\n    #[test]\n    fn encode_decode() {\n        let record = Alias {\n            name: \"k\".to_owned(),\n            value: \"kubectl\".to_owned(),\n        };\n        let record = AliasRecord::Create(record);\n\n        let snapshot = [204, 0, 146, 161, 107, 167, 107, 117, 98, 101, 99, 116, 108];\n\n        let encoded = record.serialize().unwrap();\n        let decoded = AliasRecord::deserialize(&encoded, CONFIG_SHELL_ALIAS_VERSION).unwrap();\n\n        assert_eq!(encoded.0, &snapshot);\n        assert_eq!(decoded, record);\n    }\n\n    #[tokio::test]\n    async fn build_aliases() {\n        let store = SqliteStore::new(\":memory:\", test_local_timeout())\n            .await\n            .unwrap();\n        let key: [u8; 32] = XSalsa20Poly1305::generate_key(&mut OsRng).into();\n        let host_id = atuin_common::record::HostId(atuin_common::utils::uuid_v7());\n\n        let alias = AliasStore::new(store, host_id, key);\n\n        alias.set(\"k\", \"kubectl\").await.unwrap();\n        alias.set(\"gp\", \"git push\").await.unwrap();\n        alias\n            .set(\"kgap\", \"'kubectl get pods --all-namespaces'\")\n            .await\n            .unwrap();\n\n        let mut aliases = alias.aliases().await.unwrap();\n\n        aliases.sort_by_key(|a| a.name.clone());\n\n        assert_eq!(aliases.len(), 3);\n\n        assert_eq!(\n            aliases[0],\n            Alias {\n                name: String::from(\"gp\"),\n                value: String::from(\"git push\")\n            }\n        );\n\n        assert_eq!(\n            aliases[1],\n            Alias {\n                name: String::from(\"k\"),\n                value: String::from(\"kubectl\")\n            }\n        );\n\n        assert_eq!(\n            aliases[2],\n            Alias {\n                name: String::from(\"kgap\"),\n                value: String::from(\"'kubectl get pods --all-namespaces'\")\n            }\n        );\n\n        let build = alias.posix().await.expect(\"failed to build aliases\");\n\n        assert_eq!(\n            build,\n            \"alias gp='git push'\nalias k='kubectl'\nalias kgap='kubectl get pods --all-namespaces'\n\"\n        )\n    }\n}\n"
  },
  {
    "path": "crates/atuin-hex/Cargo.toml",
    "content": "[package]\nname = \"atuin-hex\"\nedition = \"2024\"\ndescription = \"a terminal emulator for atuin\"\n\nversion = { workspace = true }\nauthors = { workspace = true }\nrust-version = { workspace = true }\nlicense = { workspace = true }\nhomepage = { workspace = true }\nrepository = { workspace = true }\n\n[dependencies]\nclap = { workspace = true }\n\n[target.'cfg(all(unix, not(target_os = \"illumos\")))'.dependencies]\ncrossterm = { workspace = true }\neyre = { workspace = true }\nportable-pty = \"0.8\"\nsignal-hook = \"0.3\"\nvt100 = \"0.15\"\n"
  },
  {
    "path": "crates/atuin-hex/src/lib.rs",
    "content": "pub mod osc133;\n\nuse clap::{Args, Subcommand, ValueEnum};\n\n#[derive(Subcommand, Debug)]\npub enum Cmd {\n    /// Print shell code to initialize atuin-hex on shell startup\n    Init(Init),\n}\n\n#[derive(Args, Debug)]\npub struct Init {\n    /// Shell to generate init for. If omitted, attempt auto-detection\n    #[arg(value_enum)]\n    shell: Option<Shell>,\n}\n\n#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]\n#[value(rename_all = \"lower\")]\n#[allow(clippy::enum_variant_names, clippy::doc_markdown)]\nenum Shell {\n    /// Zsh setup\n    Zsh,\n    /// Bash setup\n    Bash,\n    /// Fish setup\n    Fish,\n}\n\nimpl Shell {\n    fn as_str(self) -> &'static str {\n        match self {\n            Self::Bash => \"bash\",\n            Self::Zsh => \"zsh\",\n            Self::Fish => \"fish\",\n        }\n    }\n}\n\nimpl Init {\n    fn run(self) -> Result<(), String> {\n        let shell = detect_shell(self.shell)?;\n        let script = render_init(shell);\n        print!(\"{script}\");\n        Ok(())\n    }\n}\n\npub fn run(cmd: Option<Cmd>) {\n    match cmd {\n        Some(Cmd::Init(init)) => {\n            if let Err(err) = init.run() {\n                eprintln!(\"atuin hex: {err}\");\n                std::process::exit(1);\n            }\n        }\n        None => app::main(),\n    }\n}\n\nfn detect_shell(cli_shell: Option<Shell>) -> Result<Shell, String> {\n    if let Some(shell) = cli_shell {\n        return Ok(shell);\n    }\n\n    if let Ok(shell) = std::env::var(\"ATUIN_SHELL\")\n        && let Some(shell) = shell_from_name(&shell)\n    {\n        return Ok(shell);\n    }\n\n    if let Ok(shell) = std::env::var(\"SHELL\")\n        && let Some(shell) = shell_from_name(&shell)\n    {\n        return Ok(shell);\n    }\n\n    Err(\n        \"could not detect a supported shell. Please specify one explicitly: bash, zsh, or fish\"\n            .to_string(),\n    )\n}\n\nfn shell_from_name(name: &str) -> Option<Shell> {\n    let shell = name\n        .trim()\n        .rsplit('/')\n        .next()\n        .unwrap_or(name)\n        .trim_start_matches('-')\n        .to_ascii_lowercase();\n\n    match shell.as_str() {\n        \"bash\" => Some(Shell::Bash),\n        \"zsh\" => Some(Shell::Zsh),\n        \"fish\" => Some(Shell::Fish),\n        _ => None,\n    }\n}\n\nfn init_command(shell: Shell) -> String {\n    format!(\"atuin init {}\", shell.as_str())\n}\n\nfn render_init(shell: Shell) -> String {\n    let init_command = init_command(shell);\n\n    match shell {\n        Shell::Bash | Shell::Zsh => format!(\n            r#\"if [[ \"$-\" == *i* ]] && [[ -t 0 ]] && [[ -t 1 ]]; then\n  _atuin_hex_tmux_current=\"${{TMUX:-}}\"\n  _atuin_hex_tmux_previous=\"${{ATUIN_HEX_TMUX:-}}\"\n\n  if [[ -z \"${{ATUIN_HEX_ACTIVE:-}}\" ]] || [[ \"$_atuin_hex_tmux_current\" != \"$_atuin_hex_tmux_previous\" ]]; then\n    export ATUIN_HEX_ACTIVE=1\n    export ATUIN_HEX_TMUX=\"$_atuin_hex_tmux_current\"\n    exec atuin hex\n  fi\n\n  unset _atuin_hex_tmux_current _atuin_hex_tmux_previous\nfi\n\neval \"$({init_command})\"\n\"#\n        ),\n        Shell::Fish => format!(\n            r#\"if status is-interactive; and test -t 0; and test -t 1\n    set -l _atuin_hex_tmux_current \"\"\n    if set -q TMUX\n        set _atuin_hex_tmux_current \"$TMUX\"\n    end\n\n    set -l _atuin_hex_tmux_previous \"\"\n    if set -q ATUIN_HEX_TMUX\n        set _atuin_hex_tmux_previous \"$ATUIN_HEX_TMUX\"\n    end\n\n    if not set -q ATUIN_HEX_ACTIVE\n        set -gx ATUIN_HEX_ACTIVE 1\n        set -gx ATUIN_HEX_TMUX \"$_atuin_hex_tmux_current\"\n        exec atuin hex\n    else if test \"$_atuin_hex_tmux_current\" != \"$_atuin_hex_tmux_previous\"\n        set -gx ATUIN_HEX_ACTIVE 1\n        set -gx ATUIN_HEX_TMUX \"$_atuin_hex_tmux_current\"\n        exec atuin hex\n    end\nend\n\n{init_command} | source\n\"#\n        ),\n    }\n}\n\n#[cfg(any(not(unix), target_os = \"illumos\"))]\nmod app {\n    pub(crate) fn main() {\n        eprintln!(\"atuin hex currently supports unix platforms excluding illumos\");\n        std::process::exit(1);\n    }\n}\n\n#[cfg(all(unix, not(target_os = \"illumos\")))]\nmod app {\n    use std::io::{Read, Write};\n    use std::os::unix::net::UnixListener;\n    use std::sync::mpsc;\n\n    use crossterm::terminal;\n    use portable_pty::{CommandBuilder, PtySize, native_pty_system};\n\n    enum ParserMsg {\n        Data(Vec<u8>),\n        Resize { rows: u16, cols: u16 },\n        ScreenRequest(mpsc::Sender<Vec<u8>>),\n    }\n\n    pub(crate) fn main() {\n        if let Err(e) = run() {\n            let _ = terminal::disable_raw_mode();\n            eprintln!(\"atuin hex: {e:#}\");\n            std::process::exit(1);\n        }\n    }\n\n    fn socket_path() -> std::path::PathBuf {\n        let dir = std::env::temp_dir();\n        dir.join(format!(\"atuin-hex-{}.sock\", std::process::id()))\n    }\n\n    /// Wire format written to the Unix socket:\n    ///\n    /// ```text\n    /// [rows: u16 BE][cols: u16 BE][cursor_row: u16 BE][cursor_col: u16 BE]\n    /// [row_0_len: u32 BE][row_0_bytes...]\n    /// [row_1_len: u32 BE][row_1_bytes...]\n    /// ...\n    /// ```\n    ///\n    /// Each row's bytes come from `screen.rows_formatted(0, cols)` and contain\n    /// pre-built ANSI escape sequences.  The client can write them directly to\n    /// stdout without needing its own vt100 parser.\n    fn encode_screen(parser: &vt100::Parser) -> Vec<u8> {\n        let screen = parser.screen();\n        let (rows, cols) = screen.size();\n        let (cursor_row, cursor_col) = screen.cursor_position();\n\n        let mut buf: Vec<u8> = Vec::with_capacity(256 + (rows as usize * cols as usize));\n        buf.extend_from_slice(&rows.to_be_bytes());\n        buf.extend_from_slice(&cols.to_be_bytes());\n        buf.extend_from_slice(&cursor_row.to_be_bytes());\n        buf.extend_from_slice(&cursor_col.to_be_bytes());\n\n        for row_bytes in screen.rows_formatted(0, cols) {\n            let len = row_bytes.len() as u32;\n            buf.extend_from_slice(&len.to_be_bytes());\n            buf.extend_from_slice(&row_bytes);\n        }\n\n        buf\n    }\n\n    fn handle_parser_msg(parser: &mut vt100::Parser, msg: ParserMsg) {\n        match msg {\n            ParserMsg::Data(data) => parser.process(&data),\n            ParserMsg::Resize { rows, cols } => parser.set_size(rows, cols),\n            ParserMsg::ScreenRequest(reply_tx) => {\n                let _ = reply_tx.send(encode_screen(parser));\n            }\n        }\n    }\n\n    fn run() -> eyre::Result<()> {\n        let (cols, rows) = terminal::size()?;\n\n        let pty_system = native_pty_system();\n        let pair = pty_system\n            .openpty(PtySize {\n                rows,\n                cols,\n                pixel_width: 0,\n                pixel_height: 0,\n            })\n            .map_err(|e| eyre::eyre!(\"{e:#}\"))?;\n\n        // Set up socket path and expose it to child processes\n        let sock_path = socket_path();\n        // Clean up any stale socket from a previous crash\n        let _ = std::fs::remove_file(&sock_path);\n\n        let mut cmd = CommandBuilder::new_default_prog();\n        cmd.cwd(std::env::current_dir()?);\n        cmd.env(\"ATUIN_HEX_SOCKET\", sock_path.as_os_str());\n\n        let mut child = pair\n            .slave\n            .spawn_command(cmd)\n            .map_err(|e| eyre::eyre!(\"{e:#}\"))?;\n\n        // Close slave side in parent process\n        drop(pair.slave);\n\n        let mut pty_reader = pair\n            .master\n            .try_clone_reader()\n            .map_err(|e| eyre::eyre!(\"{e:#}\"))?;\n        let mut pty_writer = pair\n            .master\n            .take_writer()\n            .map_err(|e| eyre::eyre!(\"{e:#}\"))?;\n\n        // Channel: stdout/sigwinch/socket threads -> parser thread (bounded, non-blocking send)\n        let (msg_tx, msg_rx) = mpsc::sync_channel::<ParserMsg>(64);\n\n        // --- Parser thread ---\n        // Maintains a persistent vt100::Parser fed bytes as they arrive.\n        // On screen request: reads current state directly (no replay).\n        std::thread::spawn(move || {\n            let mut parser = vt100::Parser::new(rows, cols, 0);\n\n            loop {\n                // Block until at least one message arrives\n                let first = match msg_rx.recv() {\n                    Ok(msg) => msg,\n                    Err(_) => break,\n                };\n\n                handle_parser_msg(&mut parser, first);\n\n                // Drain all remaining pending messages so the parser stays\n                // caught up during high-throughput bursts (e.g. `cat bigfile`).\n                // The channel holds at most 64 items, so this is bounded.\n                while let Ok(msg) = msg_rx.try_recv() {\n                    handle_parser_msg(&mut parser, msg);\n                }\n            }\n        });\n\n        // --- Socket server thread ---\n        // Listens on Unix socket; on connection, requests screen state from parser thread.\n        {\n            let sock_path_clone = sock_path.clone();\n            let screen_tx = msg_tx.clone();\n            std::thread::spawn(move || {\n                let listener = match UnixListener::bind(&sock_path_clone) {\n                    Ok(l) => l,\n                    Err(e) => {\n                        eprintln!(\"atuin hex: failed to bind socket: {e}\");\n                        return;\n                    }\n                };\n\n                for stream in listener.incoming() {\n                    let mut stream = match stream {\n                        Ok(s) => s,\n                        Err(_) => break,\n                    };\n\n                    let (reply_tx, reply_rx) = mpsc::channel();\n                    if screen_tx.send(ParserMsg::ScreenRequest(reply_tx)).is_err() {\n                        break;\n                    }\n                    if let Ok(data) = reply_rx.recv() {\n                        let _ = stream.write_all(&data);\n                        let _ = stream.flush();\n                    }\n                }\n            });\n        }\n\n        // Handle terminal resize via SIGWINCH\n        {\n            use signal_hook::consts::SIGWINCH;\n            use signal_hook::iterator::Signals;\n\n            let master = pair.master;\n            let resize_tx = msg_tx.clone();\n            let mut signals = Signals::new([SIGWINCH])?;\n\n            std::thread::spawn(move || {\n                for _ in signals.forever() {\n                    if let Ok((cols, rows)) = terminal::size() {\n                        let _ = master.resize(PtySize {\n                            rows,\n                            cols,\n                            pixel_width: 0,\n                            pixel_height: 0,\n                        });\n                        let _ = resize_tx.try_send(ParserMsg::Resize { rows, cols });\n                    }\n                }\n            });\n        }\n\n        terminal::enable_raw_mode()?;\n\n        // PTY -> stdout (with OSC 133 parsing + buffer feed)\n        let stdout_thread = std::thread::spawn(move || {\n            let mut stdout = std::io::stdout();\n            let mut parser = crate::osc133::Parser::new();\n            let mut buf = [0u8; 8192];\n            loop {\n                match pty_reader.read(&mut buf) {\n                    Ok(0) | Err(_) => break,\n                    Ok(n) => {\n                        parser.push(&buf[..n], |_event| {\n                            // Zone transitions are tracked inside the parser.\n                            // Callers can query parser.zone() after push.\n                        });\n\n                        // Feed bytes to the shadow parser. Drops on backpressure —\n                        // the screen snapshot may be stale during bursts, but\n                        // self-corrects once output settles.\n                        let _ = msg_tx.try_send(ParserMsg::Data(buf[..n].to_vec()));\n\n                        if stdout.write_all(&buf[..n]).is_err() {\n                            break;\n                        }\n                        let _ = stdout.flush();\n                    }\n                }\n            }\n        });\n\n        // stdin -> PTY\n        std::thread::spawn(move || {\n            let mut stdin = std::io::stdin();\n            let mut buf = [0u8; 8192];\n            loop {\n                match stdin.read(&mut buf) {\n                    Ok(0) | Err(_) => break,\n                    Ok(n) => {\n                        if pty_writer.write_all(&buf[..n]).is_err() {\n                            break;\n                        }\n                    }\n                }\n            }\n        });\n\n        let status = child.wait()?;\n        let _ = stdout_thread.join();\n\n        let _ = terminal::disable_raw_mode();\n\n        // Clean up socket file\n        let _ = std::fs::remove_file(&sock_path);\n\n        std::process::exit(process_exit_code(status.exit_code()));\n    }\n\n    fn process_exit_code(code: u32) -> i32 {\n        i32::try_from(code).unwrap_or(1)\n    }\n\n    #[cfg(test)]\n    mod tests {\n        use super::process_exit_code;\n\n        #[test]\n        fn process_exit_code_preserves_valid_values() {\n            assert_eq!(process_exit_code(0), 0);\n            assert_eq!(process_exit_code(127), 127);\n            assert_eq!(process_exit_code(i32::MAX as u32), i32::MAX);\n        }\n\n        #[test]\n        fn process_exit_code_defaults_when_out_of_range() {\n            assert_eq!(process_exit_code(i32::MAX as u32 + 1), 1);\n        }\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::{Shell, init_command, render_init, shell_from_name};\n\n    #[test]\n    fn shell_from_name_handles_paths() {\n        assert_eq!(shell_from_name(\"/bin/zsh\"), Some(Shell::Zsh));\n        assert_eq!(shell_from_name(\"/usr/local/bin/bash\"), Some(Shell::Bash));\n        assert_eq!(shell_from_name(\"fish\"), Some(Shell::Fish));\n    }\n\n    #[test]\n    fn init_command_is_bootstrap_only() {\n        let command = init_command(Shell::Zsh);\n        assert_eq!(command, \"atuin init zsh\");\n    }\n\n    #[test]\n    fn posix_init_uses_exec_and_tmux_guard() {\n        let script = render_init(Shell::Bash);\n        assert!(script.contains(\"exec atuin hex\"));\n        assert!(script.contains(\"ATUIN_HEX_TMUX\"));\n        assert!(script.contains(\"eval \\\"$(atuin init bash)\\\"\"));\n    }\n\n    #[test]\n    fn fish_init_uses_source() {\n        let script = render_init(Shell::Fish);\n        assert!(script.contains(\"exec atuin hex\"));\n        assert!(script.contains(\"atuin init fish | source\"));\n    }\n}\n"
  },
  {
    "path": "crates/atuin-hex/src/osc133.rs",
    "content": "//! Streaming parser for OSC 133 (FinalTerm semantic prompt) escape sequences.\n//!\n//! OSC 133 marks four regions of a shell interaction:\n//!\n//! | Marker | Meaning                              |\n//! |--------|--------------------------------------|\n//! | A      | Prompt is about to be printed        |\n//! | B      | Prompt ended — command input begins   |\n//! | C      | Command submitted — output begins     |\n//! | D[;n]  | Command finished with exit code *n*   |\n//!\n//! The wire format is `ESC ] 133 ; <cmd> [; <params>] ST` where ST is either\n//! BEL (0x07) or ESC \\ (0x1B 0x5C).\n//!\n//! # Design goals\n//!\n//! * **Zero-copy** — the parser observes the byte stream without buffering or\n//!   modifying it.\n//! * **Zero-alloc** — after construction no heap allocation occurs.\n//! * **Non-blocking** — [`Parser::push`] processes whatever bytes are available\n//!   and returns immediately.\n//! * **Transparent** — the caller is responsible for forwarding bytes to their\n//!   destination; the parser only emits [`Event`]s through a callback.\n\n/// Events emitted when an OSC 133 marker is detected.\n#[derive(Debug, Clone, PartialEq, Eq)]\npub enum Event {\n    /// `ESC ] 133 ; A ST` — the shell is about to display its prompt.\n    PromptStart,\n    /// `ESC ] 133 ; B ST` — the prompt has ended; the user may type a command.\n    CommandStart,\n    /// `ESC ] 133 ; C ST` — the command has been submitted for execution.\n    CommandExecuted,\n    /// `ESC ] 133 ; D [; <exit_code>] ST` — command output is complete.\n    CommandFinished {\n        /// The exit code reported after the `;`, if present and valid.\n        exit_code: Option<i32>,\n    },\n}\n\n/// The current semantic zone as determined by the most recent OSC 133 marker.\n#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]\n#[allow(dead_code)]\npub enum Zone {\n    /// No marker seen yet, or after a `D` marker (between commands).\n    #[default]\n    Unknown,\n    /// Between `A` and `B` — the shell is rendering its prompt.\n    Prompt,\n    /// Between `B` and `C` — the user is editing a command line.\n    Input,\n    /// Between `C` and `D` — command output is being produced.\n    Output,\n}\n\n// ---------------------------------------------------------------------------\n// Internal constants\n// ---------------------------------------------------------------------------\n\nconst ESC: u8 = 0x1B;\nconst BEL: u8 = 0x07;\nconst BACKSLASH: u8 = b'\\\\';\nconst RIGHT_BRACKET: u8 = b']';\n\n/// Maximum bytes we'll buffer for the OSC parameter string. 32 bytes is far\n/// more than any valid OSC 133 payload needs (e.g. `133;D;127` is 9 bytes).\n/// Longer (non-133) OSC sequences simply stop accumulating once the buffer is\n/// full — the dispatch logic will harmlessly ignore them.\nconst PARAM_BUF_CAP: usize = 32;\n\n// ---------------------------------------------------------------------------\n// State machine\n// ---------------------------------------------------------------------------\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\nenum State {\n    /// Normal pass-through.\n    Ground,\n    /// Saw ESC (0x1B).\n    Esc,\n    /// Inside an OSC sequence (`ESC ]`), accumulating parameter bytes.\n    OscParam,\n    /// Inside an OSC sequence, saw ESC — next byte decides if this is `ESC \\`\n    /// (string terminator) or something else.\n    OscEsc,\n}\n\n/// A streaming, zero-allocation parser for OSC 133 escape sequences.\n///\n/// Feed arbitrary byte slices into [`Parser::push`].  The parser detects\n/// OSC 133 markers and reports [`Event`]s through a caller-supplied callback\n/// without modifying the data.  It can sit transparently between a PTY reader\n/// and stdout.\npub struct Parser {\n    state: State,\n    zone: Zone,\n    param_buf: [u8; PARAM_BUF_CAP],\n    param_len: usize,\n}\n\nimpl Default for Parser {\n    fn default() -> Self {\n        Self::new()\n    }\n}\n\nimpl Parser {\n    /// Create a new parser in the initial (ground / unknown-zone) state.\n    #[inline]\n    pub fn new() -> Self {\n        Self {\n            state: State::Ground,\n            zone: Zone::Unknown,\n            param_buf: [0u8; PARAM_BUF_CAP],\n            param_len: 0,\n        }\n    }\n\n    /// The current semantic zone based on markers seen so far.\n    #[inline]\n    #[allow(dead_code)]\n    pub fn zone(&self) -> Zone {\n        self.zone\n    }\n\n    /// Process a chunk of bytes, calling `on_event` for every OSC 133 marker\n    /// found.\n    ///\n    /// All bytes in `data` should still be forwarded to the terminal by the\n    /// caller — this method only *observes* the stream.\n    #[inline]\n    pub fn push(&mut self, data: &[u8], mut on_event: impl FnMut(Event)) {\n        for &byte in data {\n            match self.state {\n                State::Ground => {\n                    if byte == ESC {\n                        self.state = State::Esc;\n                    }\n                }\n                State::Esc => {\n                    if byte == RIGHT_BRACKET {\n                        self.state = State::OscParam;\n                        self.param_len = 0;\n                    } else {\n                        self.state = State::Ground;\n                    }\n                }\n                State::OscParam => {\n                    if byte == BEL {\n                        self.dispatch(&mut on_event);\n                        self.state = State::Ground;\n                    } else if byte == ESC {\n                        self.state = State::OscEsc;\n                    } else if self.param_len < PARAM_BUF_CAP {\n                        self.param_buf[self.param_len] = byte;\n                        self.param_len += 1;\n                    }\n                    // If param_len == PARAM_BUF_CAP we silently stop\n                    // accumulating — dispatch will ignore non-133 sequences.\n                }\n                State::OscEsc => {\n                    if byte == BACKSLASH {\n                        self.dispatch(&mut on_event);\n                    }\n                    // Whether we got a valid ST or not, return to ground.\n                    // (A new ESC ] would restart accumulation via the Ground\n                    // -> Esc -> OscParam path on the *next* byte.)\n                    self.state = State::Ground;\n                }\n            }\n        }\n    }\n\n    /// Inspect the accumulated parameter buffer.  If it holds an OSC 133\n    /// payload, emit the corresponding [`Event`] and update the zone.\n    #[inline]\n    fn dispatch(&mut self, on_event: &mut impl FnMut(Event)) {\n        let params = &self.param_buf[..self.param_len];\n\n        // Must start with \"133;\"\n        if params.len() < 5 || &params[..4] != b\"133;\" {\n            return;\n        }\n\n        let cmd = params[4];\n        let event = match cmd {\n            b'A' => {\n                self.zone = Zone::Prompt;\n                Event::PromptStart\n            }\n            b'B' => {\n                self.zone = Zone::Input;\n                Event::CommandStart\n            }\n            b'C' => {\n                self.zone = Zone::Output;\n                Event::CommandExecuted\n            }\n            b'D' => {\n                let exit_code = if params.len() > 6 && params[5] == b';' {\n                    std::str::from_utf8(&params[6..])\n                        .ok()\n                        .and_then(|s| s.parse::<i32>().ok())\n                } else {\n                    None\n                };\n                self.zone = Zone::Unknown;\n                Event::CommandFinished { exit_code }\n            }\n            _ => return,\n        };\n\n        on_event(event);\n    }\n}\n\n// ---------------------------------------------------------------------------\n// Tests\n// ---------------------------------------------------------------------------\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    /// Collect all events from a single `push` call.\n    fn parse_events(data: &[u8]) -> Vec<Event> {\n        let mut parser = Parser::new();\n        let mut events = Vec::new();\n        parser.push(data, |e| events.push(e));\n        events\n    }\n\n    // -- Basic event detection ------------------------------------------------\n\n    #[test]\n    fn detect_prompt_start_bel() {\n        let data = b\"\\x1b]133;A\\x07\";\n        assert_eq!(parse_events(data), vec![Event::PromptStart]);\n    }\n\n    #[test]\n    fn detect_prompt_start_st() {\n        let data = b\"\\x1b]133;A\\x1b\\\\\";\n        assert_eq!(parse_events(data), vec![Event::PromptStart]);\n    }\n\n    #[test]\n    fn detect_command_start_bel() {\n        let data = b\"\\x1b]133;B\\x07\";\n        assert_eq!(parse_events(data), vec![Event::CommandStart]);\n    }\n\n    #[test]\n    fn detect_command_start_st() {\n        let data = b\"\\x1b]133;B\\x1b\\\\\";\n        assert_eq!(parse_events(data), vec![Event::CommandStart]);\n    }\n\n    #[test]\n    fn detect_command_executed_bel() {\n        let data = b\"\\x1b]133;C\\x07\";\n        assert_eq!(parse_events(data), vec![Event::CommandExecuted]);\n    }\n\n    #[test]\n    fn detect_command_executed_st() {\n        let data = b\"\\x1b]133;C\\x1b\\\\\";\n        assert_eq!(parse_events(data), vec![Event::CommandExecuted]);\n    }\n\n    #[test]\n    fn detect_command_finished_no_exit_code() {\n        let data = b\"\\x1b]133;D\\x07\";\n        assert_eq!(\n            parse_events(data),\n            vec![Event::CommandFinished { exit_code: None }]\n        );\n    }\n\n    #[test]\n    fn detect_command_finished_exit_zero() {\n        let data = b\"\\x1b]133;D;0\\x07\";\n        assert_eq!(\n            parse_events(data),\n            vec![Event::CommandFinished { exit_code: Some(0) }]\n        );\n    }\n\n    #[test]\n    fn detect_command_finished_exit_nonzero() {\n        let data = b\"\\x1b]133;D;127\\x07\";\n        assert_eq!(\n            parse_events(data),\n            vec![Event::CommandFinished {\n                exit_code: Some(127)\n            }]\n        );\n    }\n\n    #[test]\n    fn detect_command_finished_negative_exit_code() {\n        let data = b\"\\x1b]133;D;-1\\x07\";\n        assert_eq!(\n            parse_events(data),\n            vec![Event::CommandFinished {\n                exit_code: Some(-1)\n            }]\n        );\n    }\n\n    #[test]\n    fn detect_command_finished_exit_code_st() {\n        let data = b\"\\x1b]133;D;42\\x1b\\\\\";\n        assert_eq!(\n            parse_events(data),\n            vec![Event::CommandFinished {\n                exit_code: Some(42)\n            }]\n        );\n    }\n\n    #[test]\n    fn invalid_exit_code_yields_none() {\n        let data = b\"\\x1b]133;D;abc\\x07\";\n        assert_eq!(\n            parse_events(data),\n            vec![Event::CommandFinished { exit_code: None }]\n        );\n    }\n\n    // -- Zone tracking --------------------------------------------------------\n\n    #[test]\n    fn zone_starts_unknown() {\n        let parser = Parser::new();\n        assert_eq!(parser.zone(), Zone::Unknown);\n    }\n\n    #[test]\n    fn full_zone_cycle() {\n        let mut parser = Parser::new();\n        let mut events = Vec::new();\n\n        parser.push(b\"\\x1b]133;A\\x07\", |e| events.push(e));\n        assert_eq!(parser.zone(), Zone::Prompt);\n\n        parser.push(b\"\\x1b]133;B\\x07\", |e| events.push(e));\n        assert_eq!(parser.zone(), Zone::Input);\n\n        parser.push(b\"\\x1b]133;C\\x07\", |e| events.push(e));\n        assert_eq!(parser.zone(), Zone::Output);\n\n        parser.push(b\"\\x1b]133;D;0\\x07\", |e| events.push(e));\n        assert_eq!(parser.zone(), Zone::Unknown);\n\n        assert_eq!(\n            events,\n            vec![\n                Event::PromptStart,\n                Event::CommandStart,\n                Event::CommandExecuted,\n                Event::CommandFinished { exit_code: Some(0) },\n            ]\n        );\n    }\n\n    // -- Multiple events in one push ------------------------------------------\n\n    #[test]\n    fn multiple_events_single_push() {\n        let data = b\"\\x1b]133;A\\x07$ \\x1b]133;B\\x07ls\\n\\x1b]133;C\\x07file.txt\\n\\x1b]133;D;0\\x07\";\n        let events = parse_events(data);\n        assert_eq!(\n            events,\n            vec![\n                Event::PromptStart,\n                Event::CommandStart,\n                Event::CommandExecuted,\n                Event::CommandFinished { exit_code: Some(0) },\n            ]\n        );\n    }\n\n    // -- Split across push boundaries -----------------------------------------\n\n    #[test]\n    fn split_esc_and_bracket() {\n        let mut parser = Parser::new();\n        let mut events = Vec::new();\n\n        parser.push(b\"\\x1b\", |e| events.push(e));\n        assert!(events.is_empty());\n\n        parser.push(b\"]133;A\\x07\", |e| events.push(e));\n        assert_eq!(events, vec![Event::PromptStart]);\n    }\n\n    #[test]\n    fn split_mid_param() {\n        let mut parser = Parser::new();\n        let mut events = Vec::new();\n\n        parser.push(b\"\\x1b]13\", |e| events.push(e));\n        assert!(events.is_empty());\n\n        parser.push(b\"3;D;42\\x07\", |e| events.push(e));\n        assert_eq!(\n            events,\n            vec![Event::CommandFinished {\n                exit_code: Some(42)\n            }]\n        );\n    }\n\n    #[test]\n    fn split_before_terminator() {\n        let mut parser = Parser::new();\n        let mut events = Vec::new();\n\n        parser.push(b\"\\x1b]133;B\", |e| events.push(e));\n        assert!(events.is_empty());\n\n        parser.push(b\"\\x07\", |e| events.push(e));\n        assert_eq!(events, vec![Event::CommandStart]);\n    }\n\n    #[test]\n    fn split_esc_backslash_terminator() {\n        let mut parser = Parser::new();\n        let mut events = Vec::new();\n\n        parser.push(b\"\\x1b]133;C\\x1b\", |e| events.push(e));\n        assert!(events.is_empty());\n\n        parser.push(b\"\\\\\", |e| events.push(e));\n        assert_eq!(events, vec![Event::CommandExecuted]);\n    }\n\n    // -- Interleaved normal text ----------------------------------------------\n\n    #[test]\n    fn normal_text_before_and_after() {\n        let data = b\"hello world\\x1b]133;A\\x07prompt text\\x1b]133;B\\x07command\";\n        let events = parse_events(data);\n        assert_eq!(events, vec![Event::PromptStart, Event::CommandStart]);\n    }\n\n    // -- Non-133 OSC sequences (should be ignored) ----------------------------\n\n    #[test]\n    fn non_133_osc_ignored() {\n        let data = b\"\\x1b]0;window title\\x07\\x1b]133;A\\x07\";\n        let events = parse_events(data);\n        assert_eq!(events, vec![Event::PromptStart]);\n    }\n\n    #[test]\n    fn osc_7_ignored() {\n        let data = b\"\\x1b]7;file:///home/user\\x07\";\n        assert!(parse_events(data).is_empty());\n    }\n\n    // -- Unknown command letter -----------------------------------------------\n\n    #[test]\n    fn unknown_command_ignored() {\n        let data = b\"\\x1b]133;Z\\x07\";\n        assert!(parse_events(data).is_empty());\n    }\n\n    // -- Malformed sequences --------------------------------------------------\n\n    #[test]\n    fn esc_followed_by_non_bracket() {\n        let data = b\"\\x1b[31m\\x1b]133;A\\x07\";\n        let events = parse_events(data);\n        assert_eq!(events, vec![Event::PromptStart]);\n    }\n\n    #[test]\n    fn lone_esc_at_end_of_chunk() {\n        let mut parser = Parser::new();\n        let mut events = Vec::new();\n\n        parser.push(b\"\\x1b\", |e| events.push(e));\n        assert!(events.is_empty());\n\n        // Feed non-bracket to abort the escape, then a real sequence.\n        parser.push(b\"x\\x1b]133;A\\x07\", |e| events.push(e));\n        assert_eq!(events, vec![Event::PromptStart]);\n    }\n\n    #[test]\n    fn truncated_133_prefix() {\n        // \"13\" followed by terminator — not \"133;\" so no event.\n        let data = b\"\\x1b]13\\x07\";\n        assert!(parse_events(data).is_empty());\n    }\n\n    #[test]\n    fn empty_osc() {\n        let data = b\"\\x1b]\\x07\";\n        assert!(parse_events(data).is_empty());\n    }\n\n    // -- Buffer overflow (very long non-133 OSC) ------------------------------\n\n    #[test]\n    fn very_long_osc_does_not_panic() {\n        let mut data = Vec::new();\n        data.extend_from_slice(b\"\\x1b]\");\n        data.extend(std::iter::repeat(b'x').take(1000));\n        data.push(BEL);\n        // Should not panic and should produce no event.\n        assert!(parse_events(&data).is_empty());\n    }\n\n    // -- Empty input ----------------------------------------------------------\n\n    #[test]\n    fn empty_input() {\n        assert!(parse_events(b\"\").is_empty());\n    }\n\n    #[test]\n    fn only_normal_text() {\n        let data = b\"just some regular terminal output\\r\\n\";\n        assert!(parse_events(data).is_empty());\n    }\n\n    // -- Repeated prompts (empty command) ------------------------------------\n\n    #[test]\n    fn repeated_prompt_cycle() {\n        let mut parser = Parser::new();\n        let mut events = Vec::new();\n\n        // User hits enter on an empty prompt twice.\n        let data = b\"\\x1b]133;A\\x07$ \\x1b]133;B\\x07\\x1b]133;D\\x07\\x1b]133;A\\x07$ \\x1b]133;B\\x07\";\n        parser.push(data, |e| events.push(e));\n\n        assert_eq!(\n            events,\n            vec![\n                Event::PromptStart,\n                Event::CommandStart,\n                Event::CommandFinished { exit_code: None },\n                Event::PromptStart,\n                Event::CommandStart,\n            ]\n        );\n        assert_eq!(parser.zone(), Zone::Input);\n    }\n\n    // -- Byte-at-a-time feeding -----------------------------------------------\n\n    #[test]\n    fn byte_at_a_time() {\n        let data = b\"\\x1b]133;D;99\\x07\";\n        let mut parser = Parser::new();\n        let mut events = Vec::new();\n\n        for &byte in data {\n            parser.push(&[byte], |e| events.push(e));\n        }\n\n        assert_eq!(\n            events,\n            vec![Event::CommandFinished {\n                exit_code: Some(99)\n            }]\n        );\n    }\n\n    // -- Mixed terminators ----------------------------------------------------\n\n    #[test]\n    fn mixed_bel_and_st_terminators() {\n        let data = b\"\\x1b]133;A\\x07\\x1b]133;B\\x1b\\\\\\x1b]133;C\\x07\\x1b]133;D;1\\x1b\\\\\";\n        let events = parse_events(data);\n        assert_eq!(\n            events,\n            vec![\n                Event::PromptStart,\n                Event::CommandStart,\n                Event::CommandExecuted,\n                Event::CommandFinished { exit_code: Some(1) },\n            ]\n        );\n    }\n\n    // -- Default trait --------------------------------------------------------\n\n    #[test]\n    fn parser_default() {\n        let parser = Parser::default();\n        assert_eq!(parser.zone(), Zone::Unknown);\n    }\n\n    #[test]\n    fn zone_default() {\n        assert_eq!(Zone::default(), Zone::Unknown);\n    }\n\n    // -- D with empty exit code field -----------------------------------------\n\n    #[test]\n    fn d_with_semicolon_but_empty_code() {\n        // \"133;D;\" — semicolon present but no digits.\n        let data = b\"\\x1b]133;D;\\x07\";\n        assert_eq!(\n            parse_events(data),\n            vec![Event::CommandFinished { exit_code: None }]\n        );\n    }\n\n    // -- Consecutive OSC sequences without gap --------------------------------\n\n    #[test]\n    fn back_to_back_osc_no_gap() {\n        let data = b\"\\x1b]133;A\\x07\\x1b]133;B\\x07\";\n        let events = parse_events(data);\n        assert_eq!(events, vec![Event::PromptStart, Event::CommandStart]);\n    }\n\n    // -- CSI sequences interleaved (should not confuse parser) ----------------\n\n    #[test]\n    fn csi_sequences_ignored() {\n        // CSI (ESC [) color codes mixed with OSC 133.\n        let data = b\"\\x1b[32m\\x1b]133;A\\x07\\x1b[0m$ \\x1b]133;B\\x07\";\n        let events = parse_events(data);\n        assert_eq!(events, vec![Event::PromptStart, Event::CommandStart]);\n    }\n\n    // -- Large exit codes -----------------------------------------------------\n\n    #[test]\n    fn large_exit_code() {\n        let data = b\"\\x1b]133;D;2147483647\\x07\";\n        assert_eq!(\n            parse_events(data),\n            vec![Event::CommandFinished {\n                exit_code: Some(i32::MAX)\n            }]\n        );\n    }\n\n    #[test]\n    fn overflow_exit_code_yields_none() {\n        let data = b\"\\x1b]133;D;9999999999999\\x07\";\n        assert_eq!(\n            parse_events(data),\n            vec![Event::CommandFinished { exit_code: None }]\n        );\n    }\n}\n"
  },
  {
    "path": "crates/atuin-history/Cargo.toml",
    "content": "[package]\nname = \"atuin-history\"\ndescription = \"The history crate for Atuin\"\nedition = \"2024\"\nversion = { workspace = true }\n\nauthors.workspace = true\nrust-version.workspace = true\nlicense.workspace = true\nhomepage.workspace = true\nrepository.workspace = true\nreadme.workspace = true\n\n# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html\n\n[dependencies]\natuin-client = { path = \"../atuin-client\", version = \"18.13.3\" }\n\ntime = { workspace = true }\nserde = { workspace = true }\ncrossterm = { workspace = true, features = [\"use-dev-tty\"] }\nunicode-segmentation = \"1.11.0\"\n\n[dev-dependencies]\ndivan = \"0.1.14\"\nrand = { workspace = true }\n\n[[bench]]\nname = \"smart_sort\"\nharness = false\n"
  },
  {
    "path": "crates/atuin-history/benches/smart_sort.rs",
    "content": "use atuin_client::history::History;\nuse atuin_history::sort::sort;\n\nuse rand::Rng;\n\nfn main() {\n    // Run registered benchmarks.\n    divan::main();\n}\n\n// Smart sort usually runs on 200 entries, test on a few sizes\n#[divan::bench(args=[100, 200, 400, 800, 1600, 10000])]\nfn smart_sort(lines: usize) {\n    // benchmark a few different sizes of \"history\"\n    // first we need to generate some history. This will use a whole bunch of memory, sorry\n    let mut rng = rand::thread_rng();\n    let now = time::OffsetDateTime::now_utc().unix_timestamp();\n\n    let possible_commands = [\"echo\", \"ls\", \"cd\", \"grep\", \"atuin\", \"curl\"];\n    let mut commands = Vec::<History>::with_capacity(lines);\n\n    for _ in 0..lines {\n        let command = possible_commands[rng.gen_range(0..possible_commands.len())];\n\n        let command = History::import()\n            .command(command)\n            .timestamp(time::OffsetDateTime::from_unix_timestamp(rng.gen_range(0..now)).unwrap())\n            .build()\n            .into();\n\n        commands.push(command);\n    }\n\n    let _ = sort(\"curl\", commands);\n}\n"
  },
  {
    "path": "crates/atuin-history/src/lib.rs",
    "content": "pub mod sort;\npub mod stats;\n"
  },
  {
    "path": "crates/atuin-history/src/sort.rs",
    "content": "use atuin_client::history::History;\n\ntype ScoredHistory = (f64, History);\n\n// Fuzzy search already comes sorted by minspan\n// This sorting should be applicable to all search modes, and solve the more \"obvious\" issues\n// first.\n// Later on, we can pass in context and do some boosts there too.\npub fn sort(query: &str, input: Vec<History>) -> Vec<History> {\n    // This can totally be extended. We need to be _careful_ that it's not slow.\n    // We also need to balance sorting db-side with sorting here. SQLite can do a lot,\n    // but some things are just much easier/more doable in Rust.\n\n    let mut scored = input\n        .into_iter()\n        .map(|h| {\n            // If history is _prefixed_ with the query, score it more highly\n            let score = if h.command.starts_with(query) {\n                2.0\n            } else if h.command.contains(query) {\n                1.75\n            } else {\n                1.0\n            };\n\n            // calculate how long ago the history was, in seconds\n            let now = time::OffsetDateTime::now_utc().unix_timestamp();\n            let time = h.timestamp.unix_timestamp();\n            let diff = std::cmp::max(1, now - time); // no /0 please\n\n            // prefer newer history, but not hugely so as to offset the other scoring\n            // the numbers will get super small over time, but I don't want time to overpower other\n            // scoring\n            #[allow(clippy::cast_precision_loss)]\n            let time_score = 1.0 + (1.0 / diff as f64);\n            let score = score * time_score;\n\n            (score, h)\n        })\n        .collect::<Vec<ScoredHistory>>();\n\n    scored.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap().reverse());\n\n    // Remove the scores and return the history\n    scored.into_iter().map(|(_, h)| h).collect::<Vec<History>>()\n}\n"
  },
  {
    "path": "crates/atuin-history/src/stats.rs",
    "content": "use std::collections::{HashMap, HashSet};\n\nuse crossterm::style::{Color, ResetColor, SetAttribute, SetForegroundColor};\nuse serde::{Deserialize, Serialize};\nuse unicode_segmentation::UnicodeSegmentation;\n\nuse atuin_client::{history::History, settings::Settings, theme::Meaning, theme::Theme};\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct Stats {\n    pub total_commands: usize,\n    pub unique_commands: usize,\n    pub top: Vec<(Vec<String>, usize)>,\n}\n\nfn first_non_whitespace(s: &str) -> Option<usize> {\n    s.char_indices()\n        // find the first non whitespace char\n        .find(|(_, c)| !c.is_ascii_whitespace())\n        // return the index of that char\n        .map(|(i, _)| i)\n}\n\nfn first_whitespace(s: &str) -> usize {\n    s.char_indices()\n        // find the first whitespace char\n        .find(|(_, c)| c.is_ascii_whitespace())\n        // return the index of that char, (or the max length of the string)\n        .map_or(s.len(), |(i, _)| i)\n}\n\nfn interesting_command<'a>(settings: &Settings, mut command: &'a str) -> &'a str {\n    // Sort by length so that we match the longest prefix first\n    let mut common_prefix = settings.stats.common_prefix.clone();\n    common_prefix.sort_by_key(|b| std::cmp::Reverse(b.len()));\n\n    // Trim off the common prefix, if it exists\n    for p in &common_prefix {\n        if command.starts_with(p) {\n            let i = p.len();\n            let prefix = &command[..i];\n            command = command[i..].trim_start();\n            if command.is_empty() {\n                // no commands following, just use the prefix\n                return prefix;\n            }\n            break;\n        }\n    }\n\n    // Sort the common_subcommands by length so that we match the longest subcommand first\n    let mut common_subcommands = settings.stats.common_subcommands.clone();\n    common_subcommands.sort_by_key(|b| std::cmp::Reverse(b.len()));\n\n    // Check for a common subcommand\n    for p in &common_subcommands {\n        if command.starts_with(p) {\n            // if the subcommand is the same length as the command, then we just use the subcommand\n            if p.len() == command.len() {\n                return command;\n            }\n            // otherwise we need to use the subcommand + the next word\n            let non_whitespace = first_non_whitespace(&command[p.len()..]).unwrap_or(0);\n            let j =\n                p.len() + non_whitespace + first_whitespace(&command[p.len() + non_whitespace..]);\n            return &command[..j];\n        }\n    }\n    // Return the first word if there is no subcommand\n    &command[..first_whitespace(command)]\n}\n\nfn split_at_pipe(command: &str) -> Vec<&str> {\n    let mut result = vec![];\n    let mut quoted = false;\n    let mut start = 0;\n    let mut graphemes = UnicodeSegmentation::grapheme_indices(command, true);\n\n    while let Some((i, c)) = graphemes.next() {\n        let current = i;\n        match c {\n            \"\\\"\" => {\n                if command[start..current] != *\"\\\"\" {\n                    quoted = !quoted;\n                }\n            }\n            \"'\" => {\n                if command[start..current] != *\"'\" {\n                    quoted = !quoted;\n                }\n            }\n            \"\\\\\" => if graphemes.next().is_some() {},\n            \"|\" => {\n                if !quoted {\n                    if current > start && command[start..].starts_with('|') {\n                        start += 1;\n                    }\n                    result.push(&command[start..current]);\n                    start = current;\n                }\n            }\n            _ => {}\n        }\n    }\n    if command[start..].starts_with('|') {\n        start += 1;\n    }\n    result.push(&command[start..]);\n    result\n}\n\nfn strip_leading_env_vars(command: &str) -> &str {\n    // fast path: no equals sign, no environment variable\n    if !command.contains('=') {\n        return command;\n    }\n\n    let mut in_token = false;\n    let mut token_start_pos = 0;\n    let mut in_single_quotes = false;\n    let mut in_double_quotes = false;\n    let mut escape_next = false;\n    let mut has_equals_outside_quotes = false;\n\n    for (i, g) in UnicodeSegmentation::grapheme_indices(command, true) {\n        if escape_next {\n            escape_next = false;\n            continue;\n        }\n\n        if !in_token {\n            token_start_pos = i;\n        }\n\n        match g {\n            \"\\\\\" => {\n                escape_next = true;\n                in_token = true;\n            }\n            \"'\" if !in_double_quotes => {\n                in_single_quotes = !in_single_quotes;\n                in_token = true;\n            }\n            \"\\\"\" if !in_single_quotes => {\n                in_double_quotes = !in_double_quotes;\n                in_token = true;\n            }\n            \"=\" if !in_single_quotes && !in_double_quotes => {\n                has_equals_outside_quotes = true;\n                in_token = true;\n            }\n            \" \" | \"\\t\" if !in_single_quotes && !in_double_quotes => {\n                if in_token {\n                    if !has_equals_outside_quotes {\n                        // if we're not in an env var, we can break early\n                        break;\n                    }\n                    in_token = false;\n                    has_equals_outside_quotes = false;\n                }\n            }\n            _ => {\n                in_token = true;\n            }\n        }\n    }\n\n    command[token_start_pos..].trim()\n}\n\npub fn pretty_print(stats: Stats, ngram_size: usize, theme: &Theme) {\n    let max = stats.top.iter().map(|x| x.1).max().unwrap();\n    let num_pad = max.ilog10() as usize + 1;\n\n    // Find the length of the longest command name for each column\n    let column_widths = stats\n        .top\n        .iter()\n        .map(|(commands, _)| commands.iter().map(|c| c.len()).collect::<Vec<usize>>())\n        .fold(vec![0; ngram_size], |acc, item| {\n            acc.iter()\n                .zip(item.iter())\n                .map(|(a, i)| *std::cmp::max(a, i))\n                .collect()\n        });\n\n    for (command, count) in stats.top {\n        let gray = SetForegroundColor(match theme.as_style(Meaning::Muted).foreground_color {\n            Some(color) => color,\n            None => Color::Grey,\n        });\n        let bold = SetAttribute(crossterm::style::Attribute::Bold);\n\n        let in_ten = 10 * count / max;\n\n        print!(\"[\");\n        print!(\n            \"{}\",\n            SetForegroundColor(match theme.get_error().foreground_color {\n                Some(color) => color,\n                None => Color::Red,\n            })\n        );\n\n        for i in 0..in_ten {\n            if i == 2 {\n                print!(\n                    \"{}\",\n                    SetForegroundColor(match theme.get_warning().foreground_color {\n                        Some(color) => color,\n                        None => Color::Yellow,\n                    })\n                );\n            }\n\n            if i == 5 {\n                print!(\n                    \"{}\",\n                    SetForegroundColor(match theme.get_info().foreground_color {\n                        Some(color) => color,\n                        None => Color::Green,\n                    })\n                );\n            }\n\n            print!(\"▮\");\n        }\n\n        for _ in in_ten..10 {\n            print!(\" \");\n        }\n\n        let formatted_command = command\n            .iter()\n            .zip(column_widths.iter())\n            .map(|(cmd, width)| format!(\"{cmd:width$}\"))\n            .collect::<Vec<_>>()\n            .join(\" | \");\n\n        println!(\n            \"{ResetColor}] {gray}{count:num_pad$}{ResetColor} {bold}{formatted_command}{ResetColor}\"\n        );\n    }\n    println!(\"Total commands:   {}\", stats.total_commands);\n    println!(\"Unique commands:  {}\", stats.unique_commands);\n}\n\npub fn compute(\n    settings: &Settings,\n    history: &[History],\n    count: usize,\n    ngram_size: usize,\n) -> Option<Stats> {\n    let mut commands = HashSet::<&str>::with_capacity(history.len());\n    let mut total_unignored = 0;\n    let mut prefixes = HashMap::<Vec<&str>, usize>::with_capacity(history.len());\n\n    for i in history {\n        // just in case it somehow has a leading tab or space or something (legacy atuin didn't ignore space prefixes)\n        let command = strip_leading_env_vars(i.command.trim());\n        let prefix = interesting_command(settings, command);\n\n        if settings.stats.ignored_commands.iter().any(|c| c == prefix) {\n            continue;\n        }\n\n        total_unignored += 1;\n        commands.insert(command);\n\n        split_at_pipe(command)\n            .iter()\n            .map(|l| {\n                let command = l.trim();\n                commands.insert(command);\n                command\n            })\n            .collect::<Vec<_>>()\n            .windows(ngram_size)\n            .for_each(|w| {\n                *prefixes\n                    .entry(w.iter().map(|c| interesting_command(settings, c)).collect())\n                    .or_default() += 1;\n            });\n    }\n\n    let unique = commands.len();\n    let mut top = prefixes.into_iter().collect::<Vec<_>>();\n\n    top.sort_unstable_by_key(|x| std::cmp::Reverse(x.1));\n    top.truncate(count);\n\n    if top.is_empty() {\n        return None;\n    }\n\n    Some(Stats {\n        unique_commands: unique,\n        total_commands: total_unignored,\n        top: top\n            .into_iter()\n            .map(|t| (t.0.into_iter().map(|s| s.to_string()).collect(), t.1))\n            .collect(),\n    })\n}\n\n#[cfg(test)]\nmod tests {\n    use atuin_client::history::History;\n    use atuin_client::settings::Settings;\n    use time::OffsetDateTime;\n\n    use super::compute;\n    use super::{interesting_command, split_at_pipe, strip_leading_env_vars};\n\n    #[test]\n    fn ignored_env_vars() {\n        let settings = Settings::utc();\n\n        let history: History = History::capture()\n            .timestamp(time::OffsetDateTime::now_utc())\n            .command(\"FOO='BAR=🚀' echo foo\")\n            .cwd(\"/\")\n            .build()\n            .into();\n\n        let stats = compute(&settings, &[history], 10, 1).expect(\"failed to compute stats\");\n        assert_eq!(stats.top.first().unwrap().0, vec![\"echo\"]);\n    }\n\n    #[test]\n    fn ignored_commands() {\n        let mut settings = Settings::utc();\n        settings.stats.ignored_commands.push(\"cd\".to_string());\n\n        let history = [\n            History::import()\n                .timestamp(OffsetDateTime::now_utc())\n                .command(\"cd foo\")\n                .build()\n                .into(),\n            History::import()\n                .timestamp(OffsetDateTime::now_utc())\n                .command(\"cargo build stuff\")\n                .build()\n                .into(),\n        ];\n\n        let stats = compute(&settings, &history, 10, 1).expect(\"failed to compute stats\");\n        assert_eq!(stats.total_commands, 1);\n        assert_eq!(stats.unique_commands, 1);\n    }\n\n    #[test]\n    fn interesting_commands() {\n        let settings = Settings::utc();\n\n        assert_eq!(interesting_command(&settings, \"cargo\"), \"cargo\");\n        assert_eq!(\n            interesting_command(&settings, \"cargo build foo bar\"),\n            \"cargo build\"\n        );\n        assert_eq!(\n            interesting_command(&settings, \"sudo   cargo build foo bar\"),\n            \"cargo build\"\n        );\n        assert_eq!(interesting_command(&settings, \"sudo\"), \"sudo\");\n    }\n\n    // Test with spaces in the common_prefix\n    #[test]\n    fn interesting_commands_spaces() {\n        let mut settings = Settings::utc();\n        settings.stats.common_prefix.push(\"sudo test\".to_string());\n\n        assert_eq!(interesting_command(&settings, \"sudo test\"), \"sudo test\");\n        assert_eq!(interesting_command(&settings, \"sudo test  \"), \"sudo test\");\n        assert_eq!(interesting_command(&settings, \"sudo test foo bar\"), \"foo\");\n        assert_eq!(\n            interesting_command(&settings, \"sudo test    foo bar\"),\n            \"foo\"\n        );\n\n        // Works with a common_subcommand as well\n        assert_eq!(\n            interesting_command(&settings, \"sudo test cargo build foo bar\"),\n            \"cargo build\"\n        );\n\n        // We still match on just the sudo prefix\n        assert_eq!(interesting_command(&settings, \"sudo\"), \"sudo\");\n        assert_eq!(interesting_command(&settings, \"sudo foo\"), \"foo\");\n    }\n\n    // Test with spaces in the common_subcommand\n    #[test]\n    fn interesting_commands_spaces_subcommand() {\n        let mut settings = Settings::utc();\n        settings\n            .stats\n            .common_subcommands\n            .push(\"cargo build\".to_string());\n\n        assert_eq!(interesting_command(&settings, \"cargo build\"), \"cargo build\");\n        assert_eq!(\n            interesting_command(&settings, \"cargo build   \"),\n            \"cargo build\"\n        );\n        assert_eq!(\n            interesting_command(&settings, \"cargo build foo bar\"),\n            \"cargo build foo\"\n        );\n\n        // Works with a common_prefix as well\n        assert_eq!(\n            interesting_command(&settings, \"sudo cargo build foo bar\"),\n            \"cargo build foo\"\n        );\n\n        // We still match on just cargo as a subcommand\n        assert_eq!(interesting_command(&settings, \"cargo\"), \"cargo\");\n        assert_eq!(interesting_command(&settings, \"cargo foo\"), \"cargo foo\");\n    }\n\n    // Test with spaces in the common_prefix and common_subcommand\n    #[test]\n    fn interesting_commands_spaces_both() {\n        let mut settings = Settings::utc();\n        settings.stats.common_prefix.push(\"sudo test\".to_string());\n        settings\n            .stats\n            .common_subcommands\n            .push(\"cargo build\".to_string());\n\n        assert_eq!(\n            interesting_command(&settings, \"sudo test cargo build\"),\n            \"cargo build\"\n        );\n        assert_eq!(\n            interesting_command(&settings, \"sudo test   cargo build\"),\n            \"cargo build\"\n        );\n        assert_eq!(\n            interesting_command(&settings, \"sudo test cargo build   \"),\n            \"cargo build\"\n        );\n        assert_eq!(\n            interesting_command(&settings, \"sudo test cargo build foo bar\"),\n            \"cargo build foo\"\n        );\n    }\n\n    #[test]\n    fn split_simple() {\n        assert_eq!(split_at_pipe(\"fd | rg\"), [\"fd \", \" rg\"]);\n    }\n\n    #[test]\n    fn split_multi() {\n        assert_eq!(\n            split_at_pipe(\"kubectl | jq | rg\"),\n            [\"kubectl \", \" jq \", \" rg\"]\n        );\n    }\n\n    #[test]\n    fn split_simple_quoted() {\n        assert_eq!(\n            split_at_pipe(\"foo | bar 'baz {} | quux' | xyzzy\"),\n            [\"foo \", \" bar 'baz {} | quux' \", \" xyzzy\"]\n        );\n    }\n\n    #[test]\n    fn split_multi_quoted() {\n        assert_eq!(\n            split_at_pipe(\"foo | bar 'baz \\\"{}\\\" | quux' | xyzzy\"),\n            [\"foo \", \" bar 'baz \\\"{}\\\" | quux' \", \" xyzzy\"]\n        );\n    }\n\n    #[test]\n    fn escaped_pipes() {\n        assert_eq!(\n            split_at_pipe(\"foo | bar baz \\\\| quux\"),\n            [\"foo \", \" bar baz \\\\| quux\"]\n        );\n    }\n\n    #[test]\n    fn emoji() {\n        assert_eq!(\n            split_at_pipe(\"git commit -m \\\"🚀\\\"\"),\n            [\"git commit -m \\\"🚀\\\"\"]\n        );\n    }\n\n    #[test]\n    fn starts_with_pipe() {\n        assert_eq!(\n            split_at_pipe(\"| sed 's/[0-9a-f]//g'\"),\n            [\"\", \" sed 's/[0-9a-f]//g'\"]\n        );\n    }\n\n    #[test]\n    fn starts_with_spaces_and_pipe() {\n        assert_eq!(\n            split_at_pipe(\"  | sed 's/[0-9a-f]//g'\"),\n            [\"  \", \" sed 's/[0-9a-f]//g'\"]\n        );\n    }\n\n    #[test]\n    fn strip_leading_env_vars_simple() {\n        assert_eq!(\n            strip_leading_env_vars(\"FOO=bar BAZ=quux echo foo\"),\n            \"echo foo\"\n        );\n    }\n\n    #[test]\n    fn strip_leading_env_vars_quoted_single() {\n        assert_eq!(strip_leading_env_vars(\"FOO='BAR=baz' echo foo\"), \"echo foo\");\n    }\n\n    #[test]\n    fn strip_leading_env_vars_quoted_double() {\n        assert_eq!(\n            strip_leading_env_vars(\"FOO=\\\"BAR=baz\\\" echo foo\"),\n            \"echo foo\"\n        );\n    }\n\n    #[test]\n    fn strip_leading_env_vars_quoted_single_and_double() {\n        assert_eq!(\n            strip_leading_env_vars(\"FOO='BAR=\\\"baz\\\"' echo foo \\\"BAR=quux\\\"\"),\n            \"echo foo \\\"BAR=quux\\\"\"\n        );\n    }\n\n    #[test]\n    fn strip_leading_env_vars_emojis() {\n        assert_eq!(\n            strip_leading_env_vars(\"FOO='BAR=🚀' echo foo \\\"BAR=quux\\\" foo\"),\n            \"echo foo \\\"BAR=quux\\\" foo\"\n        );\n    }\n\n    #[test]\n    fn strip_leading_env_vars_name_same_as_command() {\n        assert_eq!(strip_leading_env_vars(\"FOO='bar' bar baz\"), \"bar baz\");\n    }\n}\n"
  },
  {
    "path": "crates/atuin-kv/Cargo.toml",
    "content": "[package]\nname = \"atuin-kv\"\nedition = \"2024\"\nversion = { workspace = true }\ndescription = \"The kv crate for Atuin\"\n\nauthors.workspace = true\nrust-version.workspace = true\nlicense.workspace = true\nhomepage.workspace = true\nrepository.workspace = true\nreadme.workspace = true\n\n# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html\n\n[dependencies]\natuin-client = { path = \"../atuin-client\", version = \"18.13.3\" }\natuin-common = { path = \"../atuin-common\", version = \"18.13.3\" }\n\ntracing = { workspace = true }\ntracing-subscriber = { workspace = true }\nrmp = { version = \"0.8.14\" }\neyre = { workspace = true }\ntokio = { workspace = true }\ntyped-builder = { workspace = true }\npretty_assertions = { workspace = true }\nsqlx = { workspace = true }\n"
  },
  {
    "path": "crates/atuin-kv/migrations/20250501160746_create_kv_db.down.sql",
    "content": "-- Add down migration script here\nDROP TABLE kv;\n"
  },
  {
    "path": "crates/atuin-kv/migrations/20250501160746_create_kv_db.up.sql",
    "content": "-- Add up migration script here\nCREATE TABLE\n  kv (\n    namespace TEXT NOT NULL,\n    key TEXT NOT NULL,\n    value TEXT NOT NULL,\n    inserted_at INTEGER NOT NULL DEFAULT (strftime ('%s', 'now'))\n  );\n\nCREATE INDEX idx_kv_namespace ON kv (namespace);\n\nCREATE UNIQUE INDEX idx_kv ON kv (namespace, key);\n"
  },
  {
    "path": "crates/atuin-kv/src/database.rs",
    "content": "use std::{path::Path, str::FromStr, time::Duration};\n\nuse atuin_common::utils;\nuse sqlx::{\n    Result, Row,\n    sqlite::{\n        SqliteConnectOptions, SqliteJournalMode, SqlitePool, SqlitePoolOptions, SqliteRow,\n        SqliteSynchronous,\n    },\n};\nuse tokio::fs;\nuse tracing::debug;\n\nuse crate::store::entry::KvEntry;\n\n#[derive(Debug, Clone)]\npub struct Database {\n    pub pool: SqlitePool,\n}\n\nimpl Database {\n    pub async fn new(path: impl AsRef<Path>, timeout: f64) -> Result<Self> {\n        let path = path.as_ref();\n        debug!(\"opening KV sqlite database at {:?}\", path);\n\n        if utils::broken_symlink(path) {\n            eprintln!(\n                \"Atuin: KV sqlite db path ({path:?}) is a broken symlink. Unable to read or create replacement.\"\n            );\n            std::process::exit(1);\n        }\n\n        if !path.exists()\n            && let Some(dir) = path.parent()\n        {\n            fs::create_dir_all(dir).await?;\n        }\n\n        let opts = SqliteConnectOptions::from_str(path.as_os_str().to_str().unwrap())?\n            .journal_mode(SqliteJournalMode::Wal)\n            .optimize_on_close(true, None)\n            .synchronous(SqliteSynchronous::Normal)\n            .with_regexp()\n            .foreign_keys(true)\n            .create_if_missing(true);\n\n        let pool = SqlitePoolOptions::new()\n            .acquire_timeout(Duration::from_secs_f64(timeout))\n            .connect_with(opts)\n            .await?;\n\n        Self::setup_db(&pool).await?;\n        Ok(Self { pool })\n    }\n\n    pub async fn sqlite_version(&self) -> Result<String> {\n        sqlx::query_scalar(\"SELECT sqlite_version()\")\n            .fetch_one(&self.pool)\n            .await\n    }\n\n    async fn setup_db(pool: &SqlitePool) -> Result<()> {\n        debug!(\"running sqlite database setup\");\n\n        sqlx::migrate!(\"./migrations\").run(pool).await?;\n\n        Ok(())\n    }\n\n    async fn save_raw(tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>, e: &KvEntry) -> Result<()> {\n        sqlx::query(\n            \"insert into kv(namespace, key, value)\n                values(?1, ?2, ?3)\n                on conflict(namespace, key) do update set\n                    namespace = excluded.namespace,\n                    key = excluded.key,\n                    value = excluded.value\",\n        )\n        .bind(e.namespace.as_str())\n        .bind(e.key.as_str())\n        .bind(e.value.as_str())\n        .execute(&mut **tx)\n        .await?;\n\n        Ok(())\n    }\n\n    async fn delete_raw(\n        tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,\n        namespace: &str,\n        key: &str,\n    ) -> Result<()> {\n        sqlx::query(\"delete from kv where namespace = ?1 and key = ?2\")\n            .bind(namespace)\n            .bind(key)\n            .execute(&mut **tx)\n            .await?;\n        Ok(())\n    }\n\n    pub async fn save(&self, e: &KvEntry) -> Result<()> {\n        debug!(\"saving kv entry to sqlite\");\n        let mut tx = self.pool.begin().await?;\n        Self::save_raw(&mut tx, e).await?;\n        tx.commit().await?;\n\n        Ok(())\n    }\n\n    pub async fn delete(&self, namespace: &str, key: &str) -> Result<()> {\n        debug!(\"deleting kv entry {namespace}/{key}\");\n\n        let mut tx = self.pool.begin().await?;\n        Self::delete_raw(&mut tx, namespace, key).await?;\n        tx.commit().await?;\n\n        Ok(())\n    }\n\n    fn query_kv_entry(row: SqliteRow) -> KvEntry {\n        let namespace = row.get(\"namespace\");\n        let key = row.get(\"key\");\n        let value = row.get(\"value\");\n\n        KvEntry::builder()\n            .namespace(namespace)\n            .key(key)\n            .value(value)\n            .build()\n    }\n\n    pub async fn load(&self, namespace: &str, key: &str) -> Result<Option<KvEntry>> {\n        debug!(\"loading kv entry {namespace}.{key}\");\n\n        let res = sqlx::query(\"select * from kv where namespace = ?1 and key = ?2\")\n            .bind(namespace)\n            .bind(key)\n            .map(Self::query_kv_entry)\n            .fetch_optional(&self.pool)\n            .await?;\n\n        Ok(res)\n    }\n\n    pub async fn list(&self, namespace: Option<&str>) -> Result<Vec<KvEntry>> {\n        debug!(\"listing kv entries\");\n\n        let res = if let Some(namespace) = namespace {\n            sqlx::query(\"select * from kv where namespace = ?1 order by key asc\")\n                .bind(namespace)\n                .map(Self::query_kv_entry)\n                .fetch_all(&self.pool)\n                .await?\n        } else {\n            sqlx::query(\"select * from kv order by namespace, key asc\")\n                .map(Self::query_kv_entry)\n                .fetch_all(&self.pool)\n                .await?\n        };\n\n        Ok(res)\n    }\n}\n\n#[cfg(test)]\nmod test {\n    use super::*;\n\n    #[tokio::test]\n    async fn test_list() {\n        let db = Database::new(\"sqlite::memory:\", 1.0).await.unwrap();\n        let scripts = db.list(None).await.unwrap();\n        assert_eq!(scripts.len(), 0);\n\n        let entry = KvEntry::builder()\n            .namespace(\"test\".to_string())\n            .key(\"test\".to_string())\n            .value(\"test\".to_string())\n            .build();\n\n        db.save(&entry).await.unwrap();\n\n        let entries = db.list(None).await.unwrap();\n        assert_eq!(entries.len(), 1);\n        assert_eq!(entries[0].namespace, \"test\");\n        assert_eq!(entries[0].key, \"test\");\n        assert_eq!(entries[0].value, \"test\");\n    }\n\n    #[tokio::test]\n    async fn test_save_load() {\n        let db = Database::new(\"sqlite::memory:\", 1.0).await.unwrap();\n\n        let entry = KvEntry::builder()\n            .namespace(\"test\".to_string())\n            .key(\"test\".to_string())\n            .value(\"test\".to_string())\n            .build();\n\n        db.save(&entry).await.unwrap();\n\n        let loaded = db\n            .load(&entry.namespace, &entry.key)\n            .await\n            .unwrap()\n            .unwrap();\n\n        assert_eq!(loaded, entry);\n    }\n\n    #[tokio::test]\n    async fn test_delete() {\n        let db = Database::new(\"sqlite::memory:\", 1.0).await.unwrap();\n\n        let entry = KvEntry::builder()\n            .namespace(\"test\".to_string())\n            .key(\"test\".to_string())\n            .value(\"test\".to_string())\n            .build();\n\n        db.save(&entry).await.unwrap();\n\n        assert_eq!(db.list(None).await.unwrap().len(), 1);\n        db.delete(&entry.namespace, &entry.key).await.unwrap();\n\n        let loaded = db.list(None).await.unwrap();\n        assert_eq!(loaded.len(), 0);\n    }\n}\n"
  },
  {
    "path": "crates/atuin-kv/src/lib.rs",
    "content": "pub mod database;\npub mod store;\n"
  },
  {
    "path": "crates/atuin-kv/src/store/entry.rs",
    "content": "use typed_builder::TypedBuilder;\n\n#[derive(Debug, Clone, PartialEq, Eq, TypedBuilder)]\npub struct KvEntry {\n    pub namespace: String,\n    pub key: String,\n    pub value: String,\n}\n"
  },
  {
    "path": "crates/atuin-kv/src/store/record.rs",
    "content": "use atuin_common::record::DecryptedData;\nuse eyre::{Result, bail, ensure, eyre};\nuse typed_builder::TypedBuilder;\n\npub const KV_VERSION: &str = \"v1\";\npub const KV_TAG: &str = \"kv\";\npub const KV_VAL_MAX_LEN: usize = 100 * 1024;\n\n#[derive(Debug, Clone, PartialEq, Eq, TypedBuilder)]\npub struct KvRecord {\n    pub namespace: String,\n    pub key: String,\n    pub value: Option<String>,\n}\n\nimpl KvRecord {\n    pub fn serialize(&self) -> Result<DecryptedData> {\n        use rmp::encode;\n\n        let mut output = vec![];\n\n        // INFO: ensure this is updated when adding new fields\n        encode::write_array_len(&mut output, 4)?;\n\n        encode::write_str(&mut output, &self.namespace)?;\n        encode::write_str(&mut output, &self.key)?;\n        encode::write_bool(&mut output, self.value.is_some())?;\n\n        if let Some(value) = &self.value {\n            encode::write_str(&mut output, value)?;\n        }\n\n        Ok(DecryptedData(output))\n    }\n\n    pub fn deserialize(data: &DecryptedData, version: &str) -> Result<Self> {\n        use rmp::decode;\n\n        fn error_report<E: std::fmt::Debug>(err: E) -> eyre::Report {\n            eyre!(\"{err:?}\")\n        }\n\n        match version {\n            \"v0\" => {\n                let mut bytes = decode::Bytes::new(&data.0);\n\n                let nfields = decode::read_array_len(&mut bytes).map_err(error_report)?;\n                ensure!(nfields == 3, \"too many entries in v0 kv record\");\n\n                let bytes = bytes.remaining_slice();\n\n                let (namespace, bytes) =\n                    decode::read_str_from_slice(bytes).map_err(error_report)?;\n                let (key, bytes) = decode::read_str_from_slice(bytes).map_err(error_report)?;\n                let (value, bytes) = decode::read_str_from_slice(bytes).map_err(error_report)?;\n\n                if !bytes.is_empty() {\n                    bail!(\"trailing bytes in encoded kvrecord. malformed\")\n                }\n\n                Ok(KvRecord {\n                    namespace: namespace.to_owned(),\n                    key: key.to_owned(),\n                    value: Some(value.to_owned()),\n                })\n            }\n            KV_VERSION => {\n                let mut bytes = decode::Bytes::new(&data.0);\n\n                let nfields = decode::read_array_len(&mut bytes).map_err(error_report)?;\n                ensure!(nfields == 4, \"too many entries in v1 kv record\");\n\n                let bytes = bytes.remaining_slice();\n\n                let (namespace, bytes) =\n                    decode::read_str_from_slice(bytes).map_err(error_report)?;\n                let (key, mut bytes) = decode::read_str_from_slice(bytes).map_err(error_report)?;\n                let has_value = decode::read_bool(&mut bytes).map_err(error_report)?;\n\n                let (value, bytes) = if has_value {\n                    let (value, bytes) =\n                        decode::read_str_from_slice(bytes).map_err(error_report)?;\n                    (Some(value.to_owned()), bytes)\n                } else {\n                    (None, bytes)\n                };\n\n                if !bytes.is_empty() {\n                    bail!(\"trailing bytes in encoded kvrecord. malformed\")\n                }\n\n                Ok(KvRecord {\n                    namespace: namespace.to_owned(),\n                    key: key.to_owned(),\n                    value,\n                })\n            }\n            _ => {\n                bail!(\"unknown version {version:?}\")\n            }\n        }\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::{DecryptedData, KV_VERSION, KvRecord};\n\n    #[test]\n    fn encode_decode_some() {\n        let kv = KvRecord {\n            namespace: \"foo\".to_owned(),\n            key: \"bar\".to_owned(),\n            value: Some(\"baz\".to_owned()),\n        };\n        let snapshot = [\n            0x94, 0xa3, b'f', b'o', b'o', 0xa3, b'b', b'a', b'r', 0xc3, 0xa3, b'b', b'a', b'z',\n        ];\n\n        let encoded = kv.serialize().unwrap();\n        let decoded = KvRecord::deserialize(&encoded, KV_VERSION).unwrap();\n\n        assert_eq!(encoded.0, &snapshot);\n        assert_eq!(decoded, kv);\n    }\n\n    #[test]\n    fn encode_decode_none() {\n        let kv = KvRecord {\n            namespace: \"foo\".to_owned(),\n            key: \"bar\".to_owned(),\n            value: None,\n        };\n        let snapshot = [0x94, 0xa3, b'f', b'o', b'o', 0xa3, b'b', b'a', b'r', 0xc2];\n\n        let encoded = kv.serialize().unwrap();\n        let decoded = KvRecord::deserialize(&encoded, KV_VERSION).unwrap();\n\n        assert_eq!(encoded.0, &snapshot);\n        assert_eq!(decoded, kv);\n    }\n\n    #[test]\n    fn decode_v0() {\n        let kv = KvRecord {\n            namespace: \"foo\".to_owned(),\n            key: \"bar\".to_owned(),\n            value: Some(\"baz\".to_owned()),\n        };\n\n        let snapshot = vec![\n            0x93, 0xa3, b'f', b'o', b'o', 0xa3, b'b', b'a', b'r', 0xa3, b'b', b'a', b'z',\n        ];\n\n        let decoded = KvRecord::deserialize(&DecryptedData(snapshot), \"v0\").unwrap();\n\n        assert_eq!(decoded, kv);\n    }\n}\n"
  },
  {
    "path": "crates/atuin-kv/src/store.rs",
    "content": "use std::collections::HashSet;\n\nuse eyre::{Result, bail};\n\nuse atuin_client::record::sqlite_store::SqliteStore;\nuse atuin_client::record::{encryption::PASETO_V4, store::Store};\nuse atuin_common::record::{Host, HostId, Record, RecordId, RecordIdx};\nuse entry::KvEntry;\nuse record::{KV_TAG, KV_VERSION, KvRecord};\n\nuse crate::database::Database;\n\npub mod entry;\npub mod record;\n\n#[derive(Debug, Clone)]\npub struct KvStore {\n    pub record_store: SqliteStore,\n    pub kv_db: Database,\n    pub host_id: HostId,\n    pub encryption_key: [u8; 32],\n}\n\nimpl KvStore {\n    pub fn new(\n        record_store: SqliteStore,\n        kv_db: Database,\n        host_id: HostId,\n        encryption_key: [u8; 32],\n    ) -> Self {\n        KvStore {\n            record_store,\n            kv_db,\n            host_id,\n            encryption_key,\n        }\n    }\n\n    pub async fn set(&self, namespace: &str, key: &str, value: &str) -> Result<()> {\n        let kv_record = KvRecord::builder()\n            .namespace(namespace.to_string())\n            .key(key.to_string())\n            .value(Some(value.to_string()))\n            .build();\n\n        self.push_record(kv_record).await?;\n\n        let kv = KvEntry::builder()\n            .namespace(namespace.to_string())\n            .key(key.to_string())\n            .value(value.to_string())\n            .build();\n\n        self.kv_db.save(&kv).await?;\n\n        Ok(())\n    }\n\n    pub async fn get(&self, namespace: &str, key: &str) -> Result<Option<String>> {\n        let kv = self.kv_db.load(namespace, key).await?;\n        Ok(kv.map(|kv| kv.value))\n    }\n\n    pub async fn delete(&self, namespace: &str, keys: &[String]) -> Result<()> {\n        for key in keys {\n            let record = KvRecord::builder()\n                .namespace(namespace.to_string())\n                .key(key.to_string())\n                .value(None)\n                .build();\n\n            self.push_record(record).await?;\n            self.kv_db.delete(namespace, key).await?;\n        }\n\n        Ok(())\n    }\n\n    pub async fn list(&self, namespace: Option<&str>) -> Result<Vec<KvEntry>> {\n        let entries = self.kv_db.list(namespace).await?;\n\n        Ok(entries)\n    }\n\n    async fn push_record(&self, record: KvRecord) -> Result<(RecordId, RecordIdx)> {\n        let bytes = record.serialize()?;\n        let idx = self\n            .record_store\n            .last(self.host_id, KV_TAG)\n            .await?\n            .map_or(0, |p| p.idx + 1);\n\n        let record = Record::builder()\n            .host(Host::new(self.host_id))\n            .version(KV_VERSION.to_string())\n            .tag(KV_TAG.to_string())\n            .idx(idx)\n            .data(bytes)\n            .build();\n\n        let id = record.id;\n\n        self.record_store\n            .push(&record.encrypt::<PASETO_V4>(&self.encryption_key))\n            .await?;\n\n        Ok((id, idx))\n    }\n\n    pub async fn build(&self) -> Result<()> {\n        let mut tagged = self.record_store.all_tagged(KV_TAG).await?;\n        tagged.reverse();\n\n        let cached = self.kv_db.list(None).await?;\n\n        let mut visited = HashSet::new();\n\n        // Iterate through all KV records from newest to oldest;\n        // only visit each KV once, inserting or deleting based on the first time we see it\n        for record in tagged {\n            let decrypted = match record.version.as_str() {\n                \"v0\" | KV_VERSION => record.decrypt::<PASETO_V4>(&self.encryption_key)?,\n                version => bail!(\"unknown version {version:?}\"),\n            };\n\n            let kv = KvRecord::deserialize(&decrypted.data, &decrypted.version)?;\n            let uniq_id = format!(\"{}.{}\", kv.namespace, kv.key);\n\n            if visited.insert(uniq_id) {\n                match kv.value {\n                    Some(value) => {\n                        self.kv_db\n                            .save(\n                                &KvEntry::builder()\n                                    .namespace(kv.namespace.clone())\n                                    .key(kv.key.clone())\n                                    .value(value)\n                                    .build(),\n                            )\n                            .await?;\n                    }\n                    None => {\n                        self.kv_db\n                            .delete(kv.namespace.as_str(), kv.key.as_str())\n                            .await?;\n                    }\n                }\n            }\n        }\n\n        // Any KVs that were in the cache but not in the tagged list should be deleted;\n        // this should never happen in practice since the cache is always built from the tagged list,\n        // but just in case because ** S O F T W A R E **\n        for kv in cached {\n            if !visited.contains(&format!(\"{}.{}\", kv.namespace, kv.key)) {\n                self.kv_db\n                    .delete(kv.namespace.as_str(), kv.key.as_str())\n                    .await?;\n            }\n        }\n\n        Ok(())\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    async fn setup() -> Result<KvStore> {\n        let record_store = SqliteStore::new(\"sqlite::memory:\", 1.0).await.unwrap();\n        let kv_db = Database::new(\"sqlite::memory:\", 1.0).await.unwrap();\n        let host_id = atuin_common::record::HostId(atuin_common::utils::uuid_v7());\n        let encryption_key = [0; 32];\n        Ok(KvStore::new(record_store, kv_db, host_id, encryption_key))\n    }\n\n    #[tokio::test]\n    async fn test_kv_store() -> Result<()> {\n        let store = setup().await?;\n\n        store.set(\"test\", \"key\", \"value\").await.unwrap();\n        let value = store.get(\"test\", \"key\").await.unwrap();\n        assert_eq!(value, Some(\"value\".to_string()));\n\n        let records = store.record_store.all_tagged(KV_TAG).await?;\n        assert_eq!(records.len(), 1);\n\n        let list = store.list(Some(\"test\")).await.unwrap();\n        let expected = vec![\n            KvEntry::builder()\n                .namespace(\"test\".to_string())\n                .key(\"key\".to_string())\n                .value(\"value\".to_string())\n                .build(),\n        ];\n        assert_eq!(list, expected);\n\n        let ns_list = store.list(None).await.unwrap();\n        assert_eq!(ns_list, expected);\n\n        store.delete(\"test\", &[\"key\".to_string()]).await.unwrap();\n        let value = store.get(\"test\", \"key\").await.unwrap();\n        assert_eq!(value, None);\n\n        let records = store.record_store.all_tagged(KV_TAG).await?;\n        assert_eq!(records.len(), 2);\n\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "crates/atuin-nucleo/.gitignore",
    "content": "# Generated by Cargo\n# will have compiled files and executables\ndebug/\ntarget/\n\n# These are backup files generated by rustfmt\n**/*.rs.bk\n\n# MSVC Windows builds of rustc generate these, which store debugging information\n*.pdb\n"
  },
  {
    "path": "crates/atuin-nucleo/CHANGELOG.md",
    "content": "# Changelog\n\n# [0.5.0] - 2024-4-2\n\n## **Breaking Changes**\n\n* `Injector::push` now passes a reference to the push value to the closure generating the columns\n\n\n# [0.4.1] - 2024-3-11\n\n## Bugfixes\n\n* crash when restarting picker with fast active stream\n\n# [0.4.0] - 2024-2-20\n\n## Added\n\n* `active_injectors()` to retrieve the number of injectors that can potentially add new items to the matcher in the future.\n\n## Bugfixes\n\n* fix Unicode substring matcher expecting an exact match (rejecting trailing characters)\n* fix crashes and false positives in unicode substring matcher\n\n# [0.3.0] - 2023-12-22\n\n## **Breaking Changes**\n\n* Pattern API method now requires a Unicode `Normalization` strategy in addition to a `CaseMatching` strategy.\n\n## Bugfixes\n\n* avoid incorrect matches when searching for ASCII needles in a Unicode haystack\n* correctly handle Unicode normalization when there are normalizable characters in the pattern, for example characters with umlauts\n* when the needle is composed of a single char, return the score and index\n  of the best position instead of always returning the first matched character\n  in the haystack\n\n# [0.2.1] - 2023-09-02\n\n## Bugfixes\n\n* ensure matcher runs on first call to `tick`\n\n# [0.2.0] - 2023-09-01\n\n*initial public release*\n\n\n[0.3.0]: https://github.com/helix-editor/nucleo/releases/tag/nucleo-v0.3.0\n[0.2.1]: https://github.com/helix-editor/nucleo/releases/tag/nucleo-v0.2.1\n[0.2.0]: https://github.com/helix-editor/nucleo/releases/tag/nucleo-v0.2.0\n"
  },
  {
    "path": "crates/atuin-nucleo/Cargo.toml",
    "content": "[package]\nname = \"atuin-nucleo\"\ndescription = \"A fork of helix-editor/nucleo with filtering and custom scoring for Atuin\"\nauthors = [\"Pascal Kuthe <pascalkuthe@pm.me>\", \"Michelle Tilley <michelle@atuin.sh>\"]\nversion = \"0.6.0\"\nedition = \"2021\"\nlicense = \"MPL-2.0\"\nrepository = \"https://github.com/atuinsh/atuin\"\nreadme = \"README.md\"\nexclude = [\"/typos.toml\", \"/tarpaulin.toml\"]\n\n[lib]\n\n[dependencies]\natuin-nucleo-matcher = { version = \"0.3.1\", path = \"matcher\" }\nparking_lot = { version = \"0.12.1\", features = [\"send_guard\", \"arc_lock\"] }\nrayon = \"1.7.0\"\n"
  },
  {
    "path": "crates/atuin-nucleo/LICENSE",
    "content": "Mozilla Public License Version 2.0\n==================================\n\n1. Definitions\n--------------\n\n1.1. \"Contributor\"\n    means each individual or legal entity that creates, contributes to\n    the creation of, or owns Covered Software.\n\n1.2. \"Contributor Version\"\n    means the combination of the Contributions of others (if any) used\n    by a Contributor and that particular Contributor's Contribution.\n\n1.3. \"Contribution\"\n    means Covered Software of a particular Contributor.\n\n1.4. \"Covered Software\"\n    means Source Code Form to which the initial Contributor has attached\n    the notice in Exhibit A, the Executable Form of such Source Code\n    Form, and Modifications of such Source Code Form, in each case\n    including portions thereof.\n\n1.5. \"Incompatible With Secondary Licenses\"\n    means\n\n    (a) that the initial Contributor has attached the notice described\n        in Exhibit B to the Covered Software; or\n\n    (b) that the Covered Software was made available under the terms of\n        version 1.1 or earlier of the License, but not also under the\n        terms of a Secondary License.\n\n1.6. \"Executable Form\"\n    means any form of the work other than Source Code Form.\n\n1.7. \"Larger Work\"\n    means a work that combines Covered Software with other material, in\n    a separate file or files, that is not Covered Software.\n\n1.8. \"License\"\n    means this document.\n\n1.9. \"Licensable\"\n    means having the right to grant, to the maximum extent possible,\n    whether at the time of the initial grant or subsequently, any and\n    all of the rights conveyed by this License.\n\n1.10. \"Modifications\"\n    means any of the following:\n\n    (a) any file in Source Code Form that results from an addition to,\n        deletion from, or modification of the contents of Covered\n        Software; or\n\n    (b) any new file in Source Code Form that contains any Covered\n        Software.\n\n1.11. \"Patent Claims\" of a Contributor\n    means any patent claim(s), including without limitation, method,\n    process, and apparatus claims, in any patent Licensable by such\n    Contributor that would be infringed, but for the grant of the\n    License, by the making, using, selling, offering for sale, having\n    made, import, or transfer of either its Contributions or its\n    Contributor Version.\n\n1.12. \"Secondary License\"\n    means either the GNU General Public License, Version 2.0, the GNU\n    Lesser General Public License, Version 2.1, the GNU Affero General\n    Public License, Version 3.0, or any later versions of those\n    licenses.\n\n1.13. \"Source Code Form\"\n    means the form of the work preferred for making modifications.\n\n1.14. \"You\" (or \"Your\")\n    means an individual or a legal entity exercising rights under this\n    License. For legal entities, \"You\" includes any entity that\n    controls, is controlled by, or is under common control with You. For\n    purposes of this definition, \"control\" means (a) the power, direct\n    or indirect, to cause the direction or management of such entity,\n    whether by contract or otherwise, or (b) ownership of more than\n    fifty percent (50%) of the outstanding shares or beneficial\n    ownership of such entity.\n\n2. License Grants and Conditions\n--------------------------------\n\n2.1. Grants\n\nEach Contributor hereby grants You a world-wide, royalty-free,\nnon-exclusive license:\n\n(a) under intellectual property rights (other than patent or trademark)\n    Licensable by such Contributor to use, reproduce, make available,\n    modify, display, perform, distribute, and otherwise exploit its\n    Contributions, either on an unmodified basis, with Modifications, or\n    as part of a Larger Work; and\n\n(b) under Patent Claims of such Contributor to make, use, sell, offer\n    for sale, have made, import, and otherwise transfer either its\n    Contributions or its Contributor Version.\n\n2.2. Effective Date\n\nThe licenses granted in Section 2.1 with respect to any Contribution\nbecome effective for each Contribution on the date the Contributor first\ndistributes such Contribution.\n\n2.3. Limitations on Grant Scope\n\nThe licenses granted in this Section 2 are the only rights granted under\nthis License. No additional rights or licenses will be implied from the\ndistribution or licensing of Covered Software under this License.\nNotwithstanding Section 2.1(b) above, no patent license is granted by a\nContributor:\n\n(a) for any code that a Contributor has removed from Covered Software;\n    or\n\n(b) for infringements caused by: (i) Your and any other third party's\n    modifications of Covered Software, or (ii) the combination of its\n    Contributions with other software (except as part of its Contributor\n    Version); or\n\n(c) under Patent Claims infringed by Covered Software in the absence of\n    its Contributions.\n\nThis License does not grant any rights in the trademarks, service marks,\nor logos of any Contributor (except as may be necessary to comply with\nthe notice requirements in Section 3.4).\n\n2.4. Subsequent Licenses\n\nNo Contributor makes additional grants as a result of Your choice to\ndistribute the Covered Software under a subsequent version of this\nLicense (see Section 10.2) or under the terms of a Secondary License (if\npermitted under the terms of Section 3.3).\n\n2.5. Representation\n\nEach Contributor represents that the Contributor believes its\nContributions are its original creation(s) or it has sufficient rights\nto grant the rights to its Contributions conveyed by this License.\n\n2.6. Fair Use\n\nThis License is not intended to limit any rights You have under\napplicable copyright doctrines of fair use, fair dealing, or other\nequivalents.\n\n2.7. Conditions\n\nSections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted\nin Section 2.1.\n\n3. Responsibilities\n-------------------\n\n3.1. Distribution of Source Form\n\nAll distribution of Covered Software in Source Code Form, including any\nModifications that You create or to which You contribute, must be under\nthe terms of this License. You must inform recipients that the Source\nCode Form of the Covered Software is governed by the terms of this\nLicense, and how they can obtain a copy of this License. You may not\nattempt to alter or restrict the recipients' rights in the Source Code\nForm.\n\n3.2. Distribution of Executable Form\n\nIf You distribute Covered Software in Executable Form then:\n\n(a) such Covered Software must also be made available in Source Code\n    Form, as described in Section 3.1, and You must inform recipients of\n    the Executable Form how they can obtain a copy of such Source Code\n    Form by reasonable means in a timely manner, at a charge no more\n    than the cost of distribution to the recipient; and\n\n(b) You may distribute such Executable Form under the terms of this\n    License, or sublicense it under different terms, provided that the\n    license for the Executable Form does not attempt to limit or alter\n    the recipients' rights in the Source Code Form under this License.\n\n3.3. Distribution of a Larger Work\n\nYou may create and distribute a Larger Work under terms of Your choice,\nprovided that You also comply with the requirements of this License for\nthe Covered Software. If the Larger Work is a combination of Covered\nSoftware with a work governed by one or more Secondary Licenses, and the\nCovered Software is not Incompatible With Secondary Licenses, this\nLicense permits You to additionally distribute such Covered Software\nunder the terms of such Secondary License(s), so that the recipient of\nthe Larger Work may, at their option, further distribute the Covered\nSoftware under the terms of either this License or such Secondary\nLicense(s).\n\n3.4. Notices\n\nYou may not remove or alter the substance of any license notices\n(including copyright notices, patent notices, disclaimers of warranty,\nor limitations of liability) contained within the Source Code Form of\nthe Covered Software, except that You may alter any license notices to\nthe extent required to remedy known factual inaccuracies.\n\n3.5. Application of Additional Terms\n\nYou may choose to offer, and to charge a fee for, warranty, support,\nindemnity or liability obligations to one or more recipients of Covered\nSoftware. However, You may do so only on Your own behalf, and not on\nbehalf of any Contributor. You must make it absolutely clear that any\nsuch warranty, support, indemnity, or liability obligation is offered by\nYou alone, and You hereby agree to indemnify every Contributor for any\nliability incurred by such Contributor as a result of warranty, support,\nindemnity or liability terms You offer. You may include additional\ndisclaimers of warranty and limitations of liability specific to any\njurisdiction.\n\n4. Inability to Comply Due to Statute or Regulation\n---------------------------------------------------\n\nIf it is impossible for You to comply with any of the terms of this\nLicense with respect to some or all of the Covered Software due to\nstatute, judicial order, or regulation then You must: (a) comply with\nthe terms of this License to the maximum extent possible; and (b)\ndescribe the limitations and the code they affect. Such description must\nbe placed in a text file included with all distributions of the Covered\nSoftware under this License. Except to the extent prohibited by statute\nor regulation, such description must be sufficiently detailed for a\nrecipient of ordinary skill to be able to understand it.\n\n5. Termination\n--------------\n\n5.1. The rights granted under this License will terminate automatically\nif You fail to comply with any of its terms. However, if You become\ncompliant, then the rights granted under this License from a particular\nContributor are reinstated (a) provisionally, unless and until such\nContributor explicitly and finally terminates Your grants, and (b) on an\nongoing basis, if such Contributor fails to notify You of the\nnon-compliance by some reasonable means prior to 60 days after You have\ncome back into compliance. Moreover, Your grants from a particular\nContributor are reinstated on an ongoing basis if such Contributor\nnotifies You of the non-compliance by some reasonable means, this is the\nfirst time You have received notice of non-compliance with this License\nfrom such Contributor, and You become compliant prior to 30 days after\nYour receipt of the notice.\n\n5.2. If You initiate litigation against any entity by asserting a patent\ninfringement claim (excluding declaratory judgment actions,\ncounter-claims, and cross-claims) alleging that a Contributor Version\ndirectly or indirectly infringes any patent, then the rights granted to\nYou by any and all Contributors for the Covered Software under Section\n2.1 of this License shall terminate.\n\n5.3. In the event of termination under Sections 5.1 or 5.2 above, all\nend user license agreements (excluding distributors and resellers) which\nhave been validly granted by You or Your distributors under this License\nprior to termination shall survive termination.\n\n************************************************************************\n*                                                                      *\n*  6. Disclaimer of Warranty                                           *\n*  -------------------------                                           *\n*                                                                      *\n*  Covered Software is provided under this License on an \"as is\"       *\n*  basis, without warranty of any kind, either expressed, implied, or  *\n*  statutory, including, without limitation, warranties that the       *\n*  Covered Software is free of defects, merchantable, fit for a        *\n*  particular purpose or non-infringing. The entire risk as to the     *\n*  quality and performance of the Covered Software is with You.        *\n*  Should any Covered Software prove defective in any respect, You     *\n*  (not any Contributor) assume the cost of any necessary servicing,   *\n*  repair, or correction. This disclaimer of warranty constitutes an   *\n*  essential part of this License. No use of any Covered Software is   *\n*  authorized under this License except under this disclaimer.         *\n*                                                                      *\n************************************************************************\n\n************************************************************************\n*                                                                      *\n*  7. Limitation of Liability                                          *\n*  --------------------------                                          *\n*                                                                      *\n*  Under no circumstances and under no legal theory, whether tort      *\n*  (including negligence), contract, or otherwise, shall any           *\n*  Contributor, or anyone who distributes Covered Software as          *\n*  permitted above, be liable to You for any direct, indirect,         *\n*  special, incidental, or consequential damages of any character      *\n*  including, without limitation, damages for lost profits, loss of    *\n*  goodwill, work stoppage, computer failure or malfunction, or any    *\n*  and all other commercial damages or losses, even if such party      *\n*  shall have been informed of the possibility of such damages. This   *\n*  limitation of liability shall not apply to liability for death or   *\n*  personal injury resulting from such party's negligence to the       *\n*  extent applicable law prohibits such limitation. Some               *\n*  jurisdictions do not allow the exclusion or limitation of           *\n*  incidental or consequential damages, so this exclusion and          *\n*  limitation may not apply to You.                                    *\n*                                                                      *\n************************************************************************\n\n8. Litigation\n-------------\n\nAny litigation relating to this License may be brought only in the\ncourts of a jurisdiction where the defendant maintains its principal\nplace of business and such litigation shall be governed by laws of that\njurisdiction, without reference to its conflict-of-law provisions.\nNothing in this Section shall prevent a party's ability to bring\ncross-claims or counter-claims.\n\n9. Miscellaneous\n----------------\n\nThis License represents the complete agreement concerning the subject\nmatter hereof. If any provision of this License is held to be\nunenforceable, such provision shall be reformed only to the extent\nnecessary to make it enforceable. Any law or regulation which provides\nthat the language of a contract shall be construed against the drafter\nshall not be used to construe this License against a Contributor.\n\n10. Versions of the License\n---------------------------\n\n10.1. New Versions\n\nMozilla Foundation is the license steward. Except as provided in Section\n10.3, no one other than the license steward has the right to modify or\npublish new versions of this License. Each version will be given a\ndistinguishing version number.\n\n10.2. Effect of New Versions\n\nYou may distribute the Covered Software under the terms of the version\nof the License under which You originally received the Covered Software,\nor under the terms of any subsequent version published by the license\nsteward.\n\n10.3. Modified Versions\n\nIf you create software not governed by this License, and you want to\ncreate a new license for such software, you may create and use a\nmodified version of this License if you rename the license and remove\nany references to the name of the license steward (except to note that\nsuch modified license differs from this License).\n\n10.4. Distributing Source Code Form that is Incompatible With Secondary\nLicenses\n\nIf You choose to distribute Source Code Form that is Incompatible With\nSecondary Licenses under the terms of this version of the License, the\nnotice described in Exhibit B of this License must be attached.\n\nExhibit A - Source Code Form License Notice\n-------------------------------------------\n\n  This Source Code Form is subject to the terms of the Mozilla Public\n  License, v. 2.0. If a copy of the MPL was not distributed with this\n  file, You can obtain one at http://mozilla.org/MPL/2.0/.\n\nIf it is not possible or desirable to put the notice in a particular\nfile, then You may include the notice in a location (such as a LICENSE\nfile in a relevant directory) where a recipient would be likely to look\nfor such a notice.\n\nYou may add additional accurate notices of copyright ownership.\n\nExhibit B - \"Incompatible With Secondary Licenses\" Notice\n---------------------------------------------------------\n\n  This Source Code Form is \"Incompatible With Secondary Licenses\", as\n  defined by the Mozilla Public License, v. 2.0.\n"
  },
  {
    "path": "crates/atuin-nucleo/README.md",
    "content": "# nucleo-ext\n\nThis is a fork of [helix-editor/nucleo](https://github.com/helix-editor/nucleo) that includes filtering and custom scoring built into the matching loop.\n\n## Fork Changes\n\n### Filter Callback\n\nFilter items before fuzzy matching. Useful for category/facet filtering (e.g., filter by directory, host, session).\n\n```rust\nuse std::sync::Arc;\n\n// Filter to only include items where category == 1\nnucleo.set_filter(Some(Arc::new(|item: &MyItem| item.category == 1)));\n\n// Remove filter\nnucleo.set_filter(None);\n```\n\nFilters are applied:\n- During initial matching of new items\n- When rescoring existing matches after pattern changes\n- When resetting matches for empty patterns\n\nSetting a filter triggers a rescore on the next `tick()`.\n\n### Scorer Callback\n\nCompute custom scores after fuzzy matching. The scorer receives the item and the fuzzy score, and returns the final score used for sorting.\n\n```rust\nuse std::sync::Arc;\n\n// Use item priority as the score (ignoring fuzzy score)\nnucleo.set_scorer(Some(Arc::new(|item: &MyItem, _fuzzy_score| {\n    item.priority\n})));\n\n// Combine fuzzy score with frecency\nnucleo.set_scorer(Some(Arc::new(|item: &MyItem, fuzzy_score| {\n    fuzzy_score + item.frecency_score\n})));\n\n// Remove scorer (use raw fuzzy score)\nnucleo.set_scorer(None);\n```\n\nThe scorer output is stored in `Match::external_score` and used as the primary sort key.\n\n### Match Struct Changes\n\nThe `Match` struct now has an additional field:\n\n```rust\npub struct Match {\n    pub score: u32,           // Raw fuzzy match score\n    pub external_score: u32,  // Scorer output (used for sorting)\n    pub idx: u32,             // Item index\n}\n```\n\nResults are sorted by `external_score` (descending), with tie-breakers on item length and index.\n\n### Use Case: Frecency Ranking\n\nThis fork was created to support frecency-based ranking in [Atuin](https://github.com/atuinsh/atuin). The typical pattern is:\n\n1. Store metadata (frecency scores, categories) in a separate data structure\n2. Use filter callback to exclude items that don't match the current context\n3. Use scorer callback to combine fuzzy score with frecency at query time\n\n```rust\n// External data store\nlet metadata: Arc<DashMap<String, ItemMetadata>> = /* ... */;\n\n// Filter: only show items used in current directory\nlet dir = current_dir.clone();\nlet meta = metadata.clone();\nnucleo.set_filter(Some(Arc::new(move |cmd: &String| {\n    meta.get(cmd).map(|m| m.used_in_dir(&dir)).unwrap_or(false)\n})));\n\n// Scorer: combine fuzzy score with frecency\nlet meta = metadata.clone();\nnucleo.set_scorer(Some(Arc::new(move |cmd: &String, fuzzy_score| {\n    let frecency = meta.get(cmd).map(|m| m.frecency()).unwrap_or(0);\n    fuzzy_score + (frecency * 10)\n})));\n```\n\n---\n\nThe original Nucleo readme follows.\n\n---\n\n\n`nucleo` is a highly performant fuzzy matcher written in Rust. It aims to fill the same use case as `fzf` and `skim`. Compared to `fzf` `nucleo` has a significantly faster matching algorithm. This mainly makes a difference when matching patterns with low selectivity on many items. An (unscientific) comparison is shown in the benchmark section below.\n\n> Note: If you are looking for a replacement of the `fuzzy-matcher` crate and not a fully managed fuzzy picker, you should use the [`nucleo-matcher`](https://crates.io/crates/nucleo-matcher) crate.\n\n`nucleo` uses the exact **same scoring system as fzf**. That means you should get the same ranking quality (or better) as you are used to from fzf. However, `nucleo` has a more faithful implementation of the Smith-Waterman algorithm which is normally used in DNA sequence alignment (see https://www.cs.cmu.edu/~ckingsf/bioinfo-lectures/gaps.pdf) with two separate matrices (instead of one like fzf). This means that `nucleo` finds the optimal match more often. For example if you match `foo` in `xf foo` `nucleo` will match `x__foo` but `fzf` will match `xf_oo` (you can increase the word length the result will stay the same). The former is the more intuitive match and has a higher score according to the ranking system that both `nucleo` and fzf.\n\n**Compared to `skim`** (and the `fuzzy-matcher` crate) `nucleo` has an even larger performance advantage and is often around **six times faster** (see benchmarks below). Furthermore, the bonus system used by nucleo and fzf is (in my opinion) more consistent/superior. `nucleo` also handles non-ascii text much better. (`skim`s bonus system and even case insensitivity only work for ASCII).\n\nNucleo also handles Unicode graphemes more correctly. `Fzf` and `skim` both operate on Unicode code points (chars). That means that multi codepoint graphemes can have weird effects (match multiple times, weirdly change the score, ...). `nucleo` will always use the first codepoint of the grapheme for matching instead (and reports grapheme indices, so they can be highlighted correctly). \n\n## Status\n\nNucleo is used in the helix-editor and therefore has a large user base with lots of real world testing. The core matcher implementation is considered complete and is unlikely to see major changes. The `nucleo-matcher` crate is finished and ready for widespread use, breaking changes should be very rare (a 1.0 release should not be far away).\n\nWhile the high level `nucleo` crate also works well (and is also used in helix), there are still additional features that will be added in the future. The high level crate also need better documentation and will likely see a few API changes in the future.\n\n## Benchmarks\n\n> WIP currently more of a demonstration than a comprehensive benchmark suit\n> most notably scientific comparisons with `fzf` are missing (a pain because it can't be called as a library)\n\n\n### Matcher micro benchmarks\n\nBenchmark comparing the runtime of various patterns matched against all files in the source of the linux kernel. Repeat on your system with `BENCHMARK_DIR=<path_to_linux> cargo run -p benches --release` (you can specify an empty directory and the kernel is cloned automatically).\n\nMethod                 |     Mean  |    Samples\n-----------------------|-----------|-----------\nnucleo \"never_matches\" |  2.30 ms  |2,493/2,500\nskim \"never_matches\"   | 17.44 ms  |    574/574\nnucleo \"copying\"       |  2.12 ms  |2,496/2,500\nskim \"copying\"         | 16.85 ms  |    593/594\nnucleo \"/doc/kernel\"   |  2.59 ms  |2,499/2,500\nskim \"/doc/kernel\"     | 18.32 ms  |    546/546\nnucleo \"//.h\"          |  9.53 ms  |1,049/1,049\nskim \"//.h\"            | 35.46 ms  |    282/282\n\n\n### Comparison with fzf\n\nFor example in the following two screencasts the pattern `///.` is pasted into `fzf` and `nucleo` (both with about 3 million items open).\n\n`fzf` takes a while to filter the text (about 1 second) while `nucleo` has barely any noticeable delay (a single frame in the screencast so about 1/30 seconds). This comparison was made on a very beefy CPU (Ryzen 5950x) so on slower systems the difference may be larger:\n\n[![asciicast](https://asciinema.org/a/600517.svg)](https://asciinema.org/a/600517)\n[![asciicast](https://asciinema.org/a/600516.svg)](https://asciinema.org/a/600516)\n\n\n\n# Future Work\n\n* [x] merge integration into helix\n* [ ] build a standalone CLI application\n  * [ ] reach feature parity with `fzf` (mostly `--no-sort` and `--tac`)\n  * [ ] add a way to allow columnar matching\n* [ ] expose C API so both the high level API and the matching algorithm itself can be used in other applications (like various nvim plugins)\n\n# Naming\n\nThe name `nucleo` plays on the fact that the `Smith-Waterman` algorithm (that it's based on) was originally developed for matching DNA/RNA sequences. The elements of DNA/RNA that are matched are called *nucleotides* which was shortened to `nucleo` here.\n\nThe name also indicates its close relationship with the *helix* editor (sticking with the DNA theme).\n\n# Implementation Details\n\n> This is only intended for those interested and will not be relevant to most people. I plan to turn this into a blog post when I have more time\n\n<!-- Nucleo matching algorithm has `O(N-M)` space complexity while ranking/filtering (and not computing indices) compared to the `O(MN)` space complexity of fzf. --> \n\n<!-- Furthermore, `nucleo` also features fully lock-free multithreaded streaming so if used as a library its possible to performantly scale streaming to a practically unlimited number of producer threads (for example running `ignore` or `jwalk` across all cores) without any buffering or other additional logic. -->\n\n\nThe fuzzy matching algorithm is based on the `Smith-Waterman` (with affine gaps) as described in https://www.cs.cmu.edu/~ckingsf/bioinfo-lectures/gaps.pdf (TODO: explain). `Nucleo` faithfully implements this algorithm and therefore has two separate matrices. However, by precomputing the next `m-matrix` row we can avoid storing the p-matrix at all and instead just store the value in a variable as we iterate the row.\n\nNucleo also never really stores the `m-matrix` instead we only ever store the current row (which simultaneously serves as the next row). During index calculation a full matrix is however required to backtrack which indices were actually matched. We only store two bools here (to indicate where we came from in the matrix).\n\nBy comparison `skim` stores the full p and m matrix in that case. `fzf` always allocates a full `mn` matrix (even during matching!).\n\n`nucleo`s' matrix is only width `n-m+1` instead of width `n`. This comes from the observation that the `p` char requires `p-1` chars before it and `m-p` chars after it, so there are always `p-1 + m-p = m+1` chars that can never match the current char. This works especially well with only using a single row because the first relevant char is always at the same position even though it's technically further to the right. This is particularly nice because we precalculate the m-matrix row. The m-matrix is computed from diagonal elements, so the precalculated values stay in the same matrix cell. \n\nCompared to `skim` nucleo does couple simpler (but arguably even more impactful) optimizations:\n* *Presegment Unicode*: Unicode segmentation is somewhat slow and matcher will filter the same elements quite often so only doing it once is nice. It also prevents a very common source of bugs (mixing of char indices which we use here and utf8 indices) and makes the code a lot simpler as a result. Fzf does the same.\n* *Aggressive prefiltering*: Especially for ASCII this works very well, but we also do this for Unicode to a lesser extent. This ensures we reject non-matching haystacks as fast as possible. Usually most haystacks will not match when fuzzy matching large lists so having fast path for that case is a huge win.\n* *Special-case ASCII*: 90% of practical text is ASCII. ASCII can be stored as bytes instead of `chars`, so cache locality is improved a lot, and we can use `memchar` for superfast prefilters (even case-insensitive prefilter are possible that way)\n* *Fallback for very long matches*: We fall back to greedy matcher which runs in `O(N)` (and `O(1)` space complexity) to avoid the `O(mn)` blowup for large matches. This is fzfs old algorithm and yields decent (but not great) results.\n\n\n\n<!-- There is a misunderstanding in both `skim` and fzf. Basically what they do is give a bonus to each character (like word boundaries). That makes senes and is reasonable, but the problem is that they use the **maximum bonus** when multiple chars match in sequence. That means that the bonus of a character depends on which characters exactly matched around it. But the fundamental assumption of this algorithm (and why it doesn't require backtracking) is that the score of each character is independent of what other chars matched (this is the difference between the affine gap and the generic gap case shown in the paper too). During fuzzing I found many cases where this mechanism leads to a non-optimal match being reported (so the sort order and fuzzy indices would be wrong). In my testing removing this mechanism and slightly tweaking the bonus calculation results in similar match quality but made sure the algorithm always worked correctly (and removed a bunch of weird edges cases). --> \n  <!-- * [ ] it seems this makes us overemphasize word boundaries for small search strings, this is likely okay as the consecutive bonus wins fairly quickly. Maybe we just do a greedy search for the first 2 chars to reduce visual noise? -->\n<!-- * [x] substring/prefix/postfix/exact matcher -->\n<!-- * [ ] case mismatch penalty. This doesn't seem like a good idea to me. `FZF` doesn't do this (only skin), smart case should cover most cases. .would be nice for fully case-insensitive matching without smart case like in autocompletion tough. Realistically there won't be more than 3 items that are identical with different casing tough, so I don't think it matters too much. It is a bit annoying to implement since you can no longer pre-normalize queries(or need two queries) :/ -->\n<!-- * [ ] high level API (worker thread, query parsing, sorting), in progress -->\n  <!-- * apparently sorting is superfast (at most 5% of match time for `nucleo` matcher with a highly selective query, otherwise its completely negligible compared to fuzzy matching). All the bending over backwards `fzf` does (and `skim` copied but way worse) seems a little silly. I think `fzf` does it because go doesn't have a good parallel sort. `Fzf` divides the matches into a couple fairly large chunks and sorts those on each worker thread and then lazily merges the result. That makes the sorting without the merging `Nlog(N/M)` which is basically equivalent for large `N` and small `M` as is the case here. At least its parallel tough. In rust we have a great pattern defeating parallel quicksort tough (rayon) which is way easier. -->\n  <!-- * [x] basic implementation (workers, streaming, invalidation) -->\n  <!-- * [x] verify it actually works -->\n  <!-- * [x] query paring -->\n  <!-- * [x] hook up to helix -->\n  <!-- * [x] currently I simply use a tick system (called on every redraw), together with a redraw/tick nofication (ideally debounced) is that enough? yes works nicely -->\n  <!-- * [x] for streaming callers should buffer their data. Can we provide a better API for that beyond what is currently there? yes lock-free stream -->\n  <!-- * [ ] cleanup code, improve API -->\n  <!-- * [ ] write docs -->\n\n<!-- * tests -->\n  <!-- * [x] fuzz the fuzzy matcher -->\n  <!-- * [x] port the full `fzf` test suite for fuzzy matching -->\n  <!-- * [ ] port the full `skim` test suite for fuzzy matching -->\n  <!-- * [ ] highlevel API -->\n  <!-- * [~] test substring/exact/prefix/postfix match -->\n  <!-- * [ ] coverage report (fuzzy matcher was at 86%) -->\n\n"
  },
  {
    "path": "crates/atuin-nucleo/bench/Cargo.toml",
    "content": "[package]\nname = \"atuin-nucleo-bench\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[dependencies]\natuin-nucleo = { version = \"*\", path = \"../\" }\nbrunch = \"0.5.0\"\nfuzzy-matcher = \"0.3.7\"\nwalkdir = \"2\"\n"
  },
  {
    "path": "crates/atuin-nucleo/bench/src/main.rs",
    "content": "use std::hint::black_box;\nuse std::path::PathBuf;\nuse std::process::Command;\n\nuse atuin_nucleo::{Utf32Str, Utf32String};\nuse brunch::{Bench, Benches};\nuse fuzzy_matcher::FuzzyMatcher;\n\nfn bench_dir() -> PathBuf {\n    std::env::var_os(\"BENCHMARK_DIR\")\n        .expect(\"the BENCHMARK_DIR must be set to the directory to traverse for the benchmark\")\n        .into()\n}\n\nfn checkout_linux_if_needed() {\n    let linux_dir = bench_dir();\n    if !linux_dir.exists() {\n        println!(\"will git clone linux...\");\n        let output = Command::new(\"git\")\n            .arg(\"clone\")\n            .arg(\"https://github.com/BurntSushi/linux.git\")\n            .arg(\"--depth\")\n            .arg(\"1\")\n            .arg(\"--branch\")\n            .arg(\"master\")\n            .arg(\"--single-branch\")\n            .arg(&linux_dir)\n            .stdout(std::process::Stdio::inherit())\n            .status()\n            .expect(\"failed to git clone linux\");\n        println!(\"did git clone linux...{:?}\", output);\n    }\n}\n\nfn main() {\n    checkout_linux_if_needed();\n    let dir = bench_dir();\n    let paths: (Vec<Utf32String>, Vec<String>) = walkdir::WalkDir::new(dir)\n        .into_iter()\n        .filter_map(|path| {\n            let dent = path.ok()?;\n            let path = dent.into_path().to_string_lossy().into_owned();\n            Some((path.as_str().into(), path))\n        })\n        .unzip();\n    let mut nucleo = atuin_nucleo::Matcher::new(atuin_nucleo::Config::DEFAULT.match_paths());\n    let skim = fuzzy_matcher::skim::SkimMatcherV2::default();\n\n    // TODO: unicode?\n    let needles = [\"never_matches\", \"copying\", \"/doc/kernel\", \"//.h\"];\n    // Announce that we've started.\n    ::std::eprint!(\"\\x1b[1;38;5;199mStarting:\\x1b[0m Running benchmark(s). Stand by!\\n\\n\");\n    let mut benches = Benches::default();\n    // let mut scores = Vec::with_capacity(paths.0.len());\n    for needle in needles {\n        println!(\"running {needle:?}...\");\n        benches.push(Bench::new(format!(\"nucleo {needle:?}\")).run(|| {\n            // scores.clear();\n            // scores.extend(paths.0.iter().filter_map(|haystack| {\n            for haystack in &paths.0 {\n                black_box(\n                    nucleo.fuzzy_match(haystack.slice(..), Utf32Str::Ascii(needle.as_bytes())),\n                );\n            }\n            // }));\n            // scores.sort_unstable();\n        }));\n        benches.push(Bench::new(format!(\"skim {needle:?}\")).run(|| {\n            for haystack in &paths.1 {\n                let res = skim.fuzzy_match(haystack, needle);\n                let _ = black_box(res);\n            }\n        }));\n    }\n    benches.finish();\n}\n"
  },
  {
    "path": "crates/atuin-nucleo/matcher/Cargo.toml",
    "content": "[package]\nname = \"atuin-nucleo-matcher\"\ndescription = \"plug and play high performance fuzzy matcher\"\nauthors = [\"Pascal Kuthe <pascalkuthe@pm.me>\"]\nversion = \"0.3.1\"\nedition = \"2021\"\nlicense = \"MPL-2.0\"\nrepository = \"https://github.com/atuinsh/atuin\"\nreadme = \"../README.md\"\n\n[dependencies]\nmemchr = \"2.5.0\"\nunicode-segmentation = { version  = \"1.10\", optional = true }\n\n[features]\ndefault = [\"unicode-normalization\", \"unicode-casefold\", \"unicode-segmentation\"]\nunicode-normalization = []\nunicode-casefold = []\nunicode-segmentation = [\"dep:unicode-segmentation\"]\n"
  },
  {
    "path": "crates/atuin-nucleo/matcher/fuzz/.gitignore",
    "content": "target\ncorpus\nartifacts\ncoverage\n"
  },
  {
    "path": "crates/atuin-nucleo/matcher/fuzz/Cargo.toml",
    "content": "[package]\nname = \"fzf_oxide-fuzz\"\nversion = \"0.0.0\"\npublish = false\nedition = \"2021\"\n\n[package.metadata]\ncargo-fuzz = true\n\n[dependencies]\nlibfuzzer-sys = \"0.4\"\narbitrary = { version = \"1\", features = [\"derive\"] }\n\n[dependencies.fzf_oxide]\npath = \"..\"\n\n# Prevent this from interfering with workspaces\n[workspace]\nmembers = [\".\"]\n\n[profile.release]\ndebug = 1\n\n[[bin]]\nname = \"fuzz_target_1\"\npath = \"fuzz_targets/fuzz_target_1.rs\"\ntest = false\ndoc = false\n\n"
  },
  {
    "path": "crates/atuin-nucleo/matcher/fuzz/fuzz_targets/fuzz_target_1.rs",
    "content": "#![no_main]\n\nuse fzf_oxide::{chars, Matcher, MatcherConfig, Utf32Str};\nuse libfuzzer_sys::arbitrary::Arbitrary;\nuse libfuzzer_sys::fuzz_target;\n\n#[derive(Arbitrary, Debug)]\npub struct Input<'a> {\n    haystack: &'a str,\n    needle: &'a str,\n    ignore_case: bool,\n    normalize: bool,\n}\n\nfuzz_target!(|data: Input<'_>| {\n    let mut data = data;\n    let mut config = MatcherConfig::DEFAULT;\n    config.ignore_case = data.ignore_case;\n    config.normalize = data.normalize;\n    let mut matcher = Matcher::new(config);\n    let mut indices_optimal = Vec::new();\n    let mut indices_greedy = Vec::new();\n    let mut needle_buf = Vec::new();\n    let mut haystack_buf = Vec::new();\n    let normalize = |mut c: char| {\n        if config.normalize {\n            c = chars::normalize(c);\n        }\n        if config.ignore_case {\n            c = chars::to_lower_case(c);\n        }\n        c\n    };\n    let needle: String = data.needle.chars().map(normalize).collect();\n    let needle_chars: Vec<_> = needle.chars().collect();\n    let needle = Utf32Str::new(&needle, &mut needle_buf);\n    let haystack = Utf32Str::new(data.haystack, &mut haystack_buf);\n\n    let greedy_score = matcher.fuzzy_indices_greedy(haystack, needle, &mut indices_greedy);\n    if greedy_score.is_some() {\n        let match_chars: Vec<_> = indices_greedy\n            .iter()\n            .map(|&i| normalize(haystack.get(i)))\n            .collect();\n        assert_eq!(\n            match_chars, needle_chars,\n            \"failed match, found {indices_greedy:?} {match_chars:?} (greedy)\"\n        );\n    }\n    let optimal_score = matcher.fuzzy_indices(haystack, needle, &mut indices_optimal);\n    if optimal_score.is_some() {\n        let match_chars: Vec<_> = indices_optimal\n            .iter()\n            .map(|&i| normalize(haystack.get(i)))\n            .collect();\n        assert_eq!(\n            match_chars, needle_chars,\n            \"failed match, found {indices_optimal:?} {match_chars:?}\"\n        );\n    }\n    match (greedy_score, optimal_score) {\n        (None, Some(score)) => unreachable!(\"optimal matched {score} but greedy did not match\"),\n        (Some(score), None) => unreachable!(\"greedy matched {score} but optimal did not match\"),\n        (Some(greedy), Some(optimal)) => {\n            assert!(\n                greedy <= optimal,\n                \"optimal score must be at least the same as greedy score {greedy} {optimal}\"\n            );\n            if indices_greedy == indices_optimal {\n                assert_eq!(\n                    greedy, optimal,\n                    \"if matching same char greedy and optimal score should be identical\"\n                )\n            }\n        }\n        (None, None) => (),\n    }\n});\n"
  },
  {
    "path": "crates/atuin-nucleo/matcher/fuzz.sh",
    "content": "#!/usr/bin/env bash\n\ncargo +nightly fuzz \"${1}\" fuzz_target_1 \"${@:2:99}\"\n"
  },
  {
    "path": "crates/atuin-nucleo/matcher/generate_case_fold_table.sh",
    "content": "#!/usr/bin/env bash\nset -e\n\ndir=$(pwd)\nmkdir /tmp/ucd-15.0.0\ncd /tmp/ucd-15.0.0\ncurl -LO https://www.unicode.org/Public/zipped/15.0.0/UCD.zip\nunzip UCD.zip\n\ncd \"${dir}\"\ncargo install ucd-generate\nucd-generate case-folding-simple /tmp/ucd-15.0.0 --chars > src/chars/case_fold.rs\nrm -rf /tmp/ucd-15.0.0\n"
  },
  {
    "path": "crates/atuin-nucleo/matcher/src/chars/case_fold.rs",
    "content": "// DO NOT EDIT THIS FILE. IT WAS AUTOMATICALLY GENERATED BY:\n//\n//   ucd-generate case-folding-simple /tmp/ucd-15.0.0 --chars\n//\n// Unicode version: 15.0.0.\n//\n// ucd-generate 0.3.0 is available on crates.io.\n\npub const CASE_FOLDING_SIMPLE: &'static [(char, char)] = &[\n  ('A', 'a'), ('B', 'b'), ('C', 'c'), ('D', 'd'), ('E', 'e'), ('F', 'f'),\n  ('G', 'g'), ('H', 'h'), ('I', 'i'), ('J', 'j'), ('K', 'k'), ('L', 'l'),\n  ('M', 'm'), ('N', 'n'), ('O', 'o'), ('P', 'p'), ('Q', 'q'), ('R', 'r'),\n  ('S', 's'), ('T', 't'), ('U', 'u'), ('V', 'v'), ('W', 'w'), ('X', 'x'),\n  ('Y', 'y'), ('Z', 'z'), ('µ', 'μ'), ('À', 'à'), ('Á', 'á'),\n  ('Â', 'â'), ('Ã', 'ã'), ('Ä', 'ä'), ('Å', 'å'), ('Æ', 'æ'),\n  ('Ç', 'ç'), ('È', 'è'), ('É', 'é'), ('Ê', 'ê'), ('Ë', 'ë'),\n  ('Ì', 'ì'), ('Í', 'í'), ('Î', 'î'), ('Ï', 'ï'), ('Ð', 'ð'),\n  ('Ñ', 'ñ'), ('Ò', 'ò'), ('Ó', 'ó'), ('Ô', 'ô'), ('Õ', 'õ'),\n  ('Ö', 'ö'), ('Ø', 'ø'), ('Ù', 'ù'), ('Ú', 'ú'), ('Û', 'û'),\n  ('Ü', 'ü'), ('Ý', 'ý'), ('Þ', 'þ'), ('Ā', 'ā'), ('Ă', 'ă'),\n  ('Ą', 'ą'), ('Ć', 'ć'), ('Ĉ', 'ĉ'), ('Ċ', 'ċ'), ('Č', 'č'),\n  ('Ď', 'ď'), ('Đ', 'đ'), ('Ē', 'ē'), ('Ĕ', 'ĕ'), ('Ė', 'ė'),\n  ('Ę', 'ę'), ('Ě', 'ě'), ('Ĝ', 'ĝ'), ('Ğ', 'ğ'), ('Ġ', 'ġ'),\n  ('Ģ', 'ģ'), ('Ĥ', 'ĥ'), ('Ħ', 'ħ'), ('Ĩ', 'ĩ'), ('Ī', 'ī'),\n  ('Ĭ', 'ĭ'), ('Į', 'į'), ('Ĳ', 'ĳ'), ('Ĵ', 'ĵ'), ('Ķ', 'ķ'),\n  ('Ĺ', 'ĺ'), ('Ļ', 'ļ'), ('Ľ', 'ľ'), ('Ŀ', 'ŀ'), ('Ł', 'ł'),\n  ('Ń', 'ń'), ('Ņ', 'ņ'), ('Ň', 'ň'), ('Ŋ', 'ŋ'), ('Ō', 'ō'),\n  ('Ŏ', 'ŏ'), ('Ő', 'ő'), ('Œ', 'œ'), ('Ŕ', 'ŕ'), ('Ŗ', 'ŗ'),\n  ('Ř', 'ř'), ('Ś', 'ś'), ('Ŝ', 'ŝ'), ('Ş', 'ş'), ('Š', 'š'),\n  ('Ţ', 'ţ'), ('Ť', 'ť'), ('Ŧ', 'ŧ'), ('Ũ', 'ũ'), ('Ū', 'ū'),\n  ('Ŭ', 'ŭ'), ('Ů', 'ů'), ('Ű', 'ű'), ('Ų', 'ų'), ('Ŵ', 'ŵ'),\n  ('Ŷ', 'ŷ'), ('Ÿ', 'ÿ'), ('Ź', 'ź'), ('Ż', 'ż'), ('Ž', 'ž'),\n  ('ſ', 's'), ('Ɓ', 'ɓ'), ('Ƃ', 'ƃ'), ('Ƅ', 'ƅ'), ('Ɔ', 'ɔ'),\n  ('Ƈ', 'ƈ'), ('Ɖ', 'ɖ'), ('Ɗ', 'ɗ'), ('Ƌ', 'ƌ'), ('Ǝ', 'ǝ'),\n  ('Ə', 'ə'), ('Ɛ', 'ɛ'), ('Ƒ', 'ƒ'), ('Ɠ', 'ɠ'), ('Ɣ', 'ɣ'),\n  ('Ɩ', 'ɩ'), ('Ɨ', 'ɨ'), ('Ƙ', 'ƙ'), ('Ɯ', 'ɯ'), ('Ɲ', 'ɲ'),\n  ('Ɵ', 'ɵ'), ('Ơ', 'ơ'), ('Ƣ', 'ƣ'), ('Ƥ', 'ƥ'), ('Ʀ', 'ʀ'),\n  ('Ƨ', 'ƨ'), ('Ʃ', 'ʃ'), ('Ƭ', 'ƭ'), ('Ʈ', 'ʈ'), ('Ư', 'ư'),\n  ('Ʊ', 'ʊ'), ('Ʋ', 'ʋ'), ('Ƴ', 'ƴ'), ('Ƶ', 'ƶ'), ('Ʒ', 'ʒ'),\n  ('Ƹ', 'ƹ'), ('Ƽ', 'ƽ'), ('Ǆ', 'ǆ'), ('ǅ', 'ǆ'), ('Ǉ', 'ǉ'),\n  ('ǈ', 'ǉ'), ('Ǌ', 'ǌ'), ('ǋ', 'ǌ'), ('Ǎ', 'ǎ'), ('Ǐ', 'ǐ'),\n  ('Ǒ', 'ǒ'), ('Ǔ', 'ǔ'), ('Ǖ', 'ǖ'), ('Ǘ', 'ǘ'), ('Ǚ', 'ǚ'),\n  ('Ǜ', 'ǜ'), ('Ǟ', 'ǟ'), ('Ǡ', 'ǡ'), ('Ǣ', 'ǣ'), ('Ǥ', 'ǥ'),\n  ('Ǧ', 'ǧ'), ('Ǩ', 'ǩ'), ('Ǫ', 'ǫ'), ('Ǭ', 'ǭ'), ('Ǯ', 'ǯ'),\n  ('Ǳ', 'ǳ'), ('ǲ', 'ǳ'), ('Ǵ', 'ǵ'), ('Ƕ', 'ƕ'), ('Ƿ', 'ƿ'),\n  ('Ǹ', 'ǹ'), ('Ǻ', 'ǻ'), ('Ǽ', 'ǽ'), ('Ǿ', 'ǿ'), ('Ȁ', 'ȁ'),\n  ('Ȃ', 'ȃ'), ('Ȅ', 'ȅ'), ('Ȇ', 'ȇ'), ('Ȉ', 'ȉ'), ('Ȋ', 'ȋ'),\n  ('Ȍ', 'ȍ'), ('Ȏ', 'ȏ'), ('Ȑ', 'ȑ'), ('Ȓ', 'ȓ'), ('Ȕ', 'ȕ'),\n  ('Ȗ', 'ȗ'), ('Ș', 'ș'), ('Ț', 'ț'), ('Ȝ', 'ȝ'), ('Ȟ', 'ȟ'),\n  ('Ƞ', 'ƞ'), ('Ȣ', 'ȣ'), ('Ȥ', 'ȥ'), ('Ȧ', 'ȧ'), ('Ȩ', 'ȩ'),\n  ('Ȫ', 'ȫ'), ('Ȭ', 'ȭ'), ('Ȯ', 'ȯ'), ('Ȱ', 'ȱ'), ('Ȳ', 'ȳ'),\n  ('Ⱥ', 'ⱥ'), ('Ȼ', 'ȼ'), ('Ƚ', 'ƚ'), ('Ⱦ', 'ⱦ'), ('Ɂ', 'ɂ'),\n  ('Ƀ', 'ƀ'), ('Ʉ', 'ʉ'), ('Ʌ', 'ʌ'), ('Ɇ', 'ɇ'), ('Ɉ', 'ɉ'),\n  ('Ɋ', 'ɋ'), ('Ɍ', 'ɍ'), ('Ɏ', 'ɏ'), ('\\u{345}', 'ι'), ('Ͱ', 'ͱ'),\n  ('Ͳ', 'ͳ'), ('Ͷ', 'ͷ'), ('Ϳ', 'ϳ'), ('Ά', 'ά'), ('Έ', 'έ'),\n  ('Ή', 'ή'), ('Ί', 'ί'), ('Ό', 'ό'), ('Ύ', 'ύ'), ('Ώ', 'ώ'),\n  ('Α', 'α'), ('Β', 'β'), ('Γ', 'γ'), ('Δ', 'δ'), ('Ε', 'ε'),\n  ('Ζ', 'ζ'), ('Η', 'η'), ('Θ', 'θ'), ('Ι', 'ι'), ('Κ', 'κ'),\n  ('Λ', 'λ'), ('Μ', 'μ'), ('Ν', 'ν'), ('Ξ', 'ξ'), ('Ο', 'ο'),\n  ('Π', 'π'), ('Ρ', 'ρ'), ('Σ', 'σ'), ('Τ', 'τ'), ('Υ', 'υ'),\n  ('Φ', 'φ'), ('Χ', 'χ'), ('Ψ', 'ψ'), ('Ω', 'ω'), ('Ϊ', 'ϊ'),\n  ('Ϋ', 'ϋ'), ('ς', 'σ'), ('Ϗ', 'ϗ'), ('ϐ', 'β'), ('ϑ', 'θ'),\n  ('ϕ', 'φ'), ('ϖ', 'π'), ('Ϙ', 'ϙ'), ('Ϛ', 'ϛ'), ('Ϝ', 'ϝ'),\n  ('Ϟ', 'ϟ'), ('Ϡ', 'ϡ'), ('Ϣ', 'ϣ'), ('Ϥ', 'ϥ'), ('Ϧ', 'ϧ'),\n  ('Ϩ', 'ϩ'), ('Ϫ', 'ϫ'), ('Ϭ', 'ϭ'), ('Ϯ', 'ϯ'), ('ϰ', 'κ'),\n  ('ϱ', 'ρ'), ('ϴ', 'θ'), ('ϵ', 'ε'), ('Ϸ', 'ϸ'), ('Ϲ', 'ϲ'),\n  ('Ϻ', 'ϻ'), ('Ͻ', 'ͻ'), ('Ͼ', 'ͼ'), ('Ͽ', 'ͽ'), ('Ѐ', 'ѐ'),\n  ('Ё', 'ё'), ('Ђ', 'ђ'), ('Ѓ', 'ѓ'), ('Є', 'є'), ('Ѕ', 'ѕ'),\n  ('І', 'і'), ('Ї', 'ї'), ('Ј', 'ј'), ('Љ', 'љ'), ('Њ', 'њ'),\n  ('Ћ', 'ћ'), ('Ќ', 'ќ'), ('Ѝ', 'ѝ'), ('Ў', 'ў'), ('Џ', 'џ'),\n  ('А', 'а'), ('Б', 'б'), ('В', 'в'), ('Г', 'г'), ('Д', 'д'),\n  ('Е', 'е'), ('Ж', 'ж'), ('З', 'з'), ('И', 'и'), ('Й', 'й'),\n  ('К', 'к'), ('Л', 'л'), ('М', 'м'), ('Н', 'н'), ('О', 'о'),\n  ('П', 'п'), ('Р', 'р'), ('С', 'с'), ('Т', 'т'), ('У', 'у'),\n  ('Ф', 'ф'), ('Х', 'х'), ('Ц', 'ц'), ('Ч', 'ч'), ('Ш', 'ш'),\n  ('Щ', 'щ'), ('Ъ', 'ъ'), ('Ы', 'ы'), ('Ь', 'ь'), ('Э', 'э'),\n  ('Ю', 'ю'), ('Я', 'я'), ('Ѡ', 'ѡ'), ('Ѣ', 'ѣ'), ('Ѥ', 'ѥ'),\n  ('Ѧ', 'ѧ'), ('Ѩ', 'ѩ'), ('Ѫ', 'ѫ'), ('Ѭ', 'ѭ'), ('Ѯ', 'ѯ'),\n  ('Ѱ', 'ѱ'), ('Ѳ', 'ѳ'), ('Ѵ', 'ѵ'), ('Ѷ', 'ѷ'), ('Ѹ', 'ѹ'),\n  ('Ѻ', 'ѻ'), ('Ѽ', 'ѽ'), ('Ѿ', 'ѿ'), ('Ҁ', 'ҁ'), ('Ҋ', 'ҋ'),\n  ('Ҍ', 'ҍ'), ('Ҏ', 'ҏ'), ('Ґ', 'ґ'), ('Ғ', 'ғ'), ('Ҕ', 'ҕ'),\n  ('Җ', 'җ'), ('Ҙ', 'ҙ'), ('Қ', 'қ'), ('Ҝ', 'ҝ'), ('Ҟ', 'ҟ'),\n  ('Ҡ', 'ҡ'), ('Ң', 'ң'), ('Ҥ', 'ҥ'), ('Ҧ', 'ҧ'), ('Ҩ', 'ҩ'),\n  ('Ҫ', 'ҫ'), ('Ҭ', 'ҭ'), ('Ү', 'ү'), ('Ұ', 'ұ'), ('Ҳ', 'ҳ'),\n  ('Ҵ', 'ҵ'), ('Ҷ', 'ҷ'), ('Ҹ', 'ҹ'), ('Һ', 'һ'), ('Ҽ', 'ҽ'),\n  ('Ҿ', 'ҿ'), ('Ӏ', 'ӏ'), ('Ӂ', 'ӂ'), ('Ӄ', 'ӄ'), ('Ӆ', 'ӆ'),\n  ('Ӈ', 'ӈ'), ('Ӊ', 'ӊ'), ('Ӌ', 'ӌ'), ('Ӎ', 'ӎ'), ('Ӑ', 'ӑ'),\n  ('Ӓ', 'ӓ'), ('Ӕ', 'ӕ'), ('Ӗ', 'ӗ'), ('Ә', 'ә'), ('Ӛ', 'ӛ'),\n  ('Ӝ', 'ӝ'), ('Ӟ', 'ӟ'), ('Ӡ', 'ӡ'), ('Ӣ', 'ӣ'), ('Ӥ', 'ӥ'),\n  ('Ӧ', 'ӧ'), ('Ө', 'ө'), ('Ӫ', 'ӫ'), ('Ӭ', 'ӭ'), ('Ӯ', 'ӯ'),\n  ('Ӱ', 'ӱ'), ('Ӳ', 'ӳ'), ('Ӵ', 'ӵ'), ('Ӷ', 'ӷ'), ('Ӹ', 'ӹ'),\n  ('Ӻ', 'ӻ'), ('Ӽ', 'ӽ'), ('Ӿ', 'ӿ'), ('Ԁ', 'ԁ'), ('Ԃ', 'ԃ'),\n  ('Ԅ', 'ԅ'), ('Ԇ', 'ԇ'), ('Ԉ', 'ԉ'), ('Ԋ', 'ԋ'), ('Ԍ', 'ԍ'),\n  ('Ԏ', 'ԏ'), ('Ԑ', 'ԑ'), ('Ԓ', 'ԓ'), ('Ԕ', 'ԕ'), ('Ԗ', 'ԗ'),\n  ('Ԙ', 'ԙ'), ('Ԛ', 'ԛ'), ('Ԝ', 'ԝ'), ('Ԟ', 'ԟ'), ('Ԡ', 'ԡ'),\n  ('Ԣ', 'ԣ'), ('Ԥ', 'ԥ'), ('Ԧ', 'ԧ'), ('Ԩ', 'ԩ'), ('Ԫ', 'ԫ'),\n  ('Ԭ', 'ԭ'), ('Ԯ', 'ԯ'), ('Ա', 'ա'), ('Բ', 'բ'), ('Գ', 'գ'),\n  ('Դ', 'դ'), ('Ե', 'ե'), ('Զ', 'զ'), ('Է', 'է'), ('Ը', 'ը'),\n  ('Թ', 'թ'), ('Ժ', 'ժ'), ('Ի', 'ի'), ('Լ', 'լ'), ('Խ', 'խ'),\n  ('Ծ', 'ծ'), ('Կ', 'կ'), ('Հ', 'հ'), ('Ձ', 'ձ'), ('Ղ', 'ղ'),\n  ('Ճ', 'ճ'), ('Մ', 'մ'), ('Յ', 'յ'), ('Ն', 'ն'), ('Շ', 'շ'),\n  ('Ո', 'ո'), ('Չ', 'չ'), ('Պ', 'պ'), ('Ջ', 'ջ'), ('Ռ', 'ռ'),\n  ('Ս', 'ս'), ('Վ', 'վ'), ('Տ', 'տ'), ('Ր', 'ր'), ('Ց', 'ց'),\n  ('Ւ', 'ւ'), ('Փ', 'փ'), ('Ք', 'ք'), ('Օ', 'օ'), ('Ֆ', 'ֆ'),\n  ('Ⴀ', 'ⴀ'), ('Ⴁ', 'ⴁ'), ('Ⴂ', 'ⴂ'), ('Ⴃ', 'ⴃ'),\n  ('Ⴄ', 'ⴄ'), ('Ⴅ', 'ⴅ'), ('Ⴆ', 'ⴆ'), ('Ⴇ', 'ⴇ'),\n  ('Ⴈ', 'ⴈ'), ('Ⴉ', 'ⴉ'), ('Ⴊ', 'ⴊ'), ('Ⴋ', 'ⴋ'),\n  ('Ⴌ', 'ⴌ'), ('Ⴍ', 'ⴍ'), ('Ⴎ', 'ⴎ'), ('Ⴏ', 'ⴏ'),\n  ('Ⴐ', 'ⴐ'), ('Ⴑ', 'ⴑ'), ('Ⴒ', 'ⴒ'), ('Ⴓ', 'ⴓ'),\n  ('Ⴔ', 'ⴔ'), ('Ⴕ', 'ⴕ'), ('Ⴖ', 'ⴖ'), ('Ⴗ', 'ⴗ'),\n  ('Ⴘ', 'ⴘ'), ('Ⴙ', 'ⴙ'), ('Ⴚ', 'ⴚ'), ('Ⴛ', 'ⴛ'),\n  ('Ⴜ', 'ⴜ'), ('Ⴝ', 'ⴝ'), ('Ⴞ', 'ⴞ'), ('Ⴟ', 'ⴟ'),\n  ('Ⴠ', 'ⴠ'), ('Ⴡ', 'ⴡ'), ('Ⴢ', 'ⴢ'), ('Ⴣ', 'ⴣ'),\n  ('Ⴤ', 'ⴤ'), ('Ⴥ', 'ⴥ'), ('Ⴧ', 'ⴧ'), ('Ⴭ', 'ⴭ'),\n  ('ᏸ', 'Ᏸ'), ('ᏹ', 'Ᏹ'), ('ᏺ', 'Ᏺ'), ('ᏻ', 'Ᏻ'),\n  ('ᏼ', 'Ᏼ'), ('ᏽ', 'Ᏽ'), ('ᲀ', 'в'), ('ᲁ', 'д'), ('ᲂ', 'о'),\n  ('ᲃ', 'с'), ('ᲄ', 'т'), ('ᲅ', 'т'), ('ᲆ', 'ъ'), ('ᲇ', 'ѣ'),\n  ('ᲈ', 'ꙋ'), ('Ა', 'ა'), ('Ბ', 'ბ'), ('Გ', 'გ'),\n  ('Დ', 'დ'), ('Ე', 'ე'), ('Ვ', 'ვ'), ('Ზ', 'ზ'),\n  ('Თ', 'თ'), ('Ი', 'ი'), ('Კ', 'კ'), ('Ლ', 'ლ'),\n  ('Მ', 'მ'), ('Ნ', 'ნ'), ('Ო', 'ო'), ('Პ', 'პ'),\n  ('Ჟ', 'ჟ'), ('Რ', 'რ'), ('Ს', 'ს'), ('Ტ', 'ტ'),\n  ('Უ', 'უ'), ('Ფ', 'ფ'), ('Ქ', 'ქ'), ('Ღ', 'ღ'),\n  ('Ყ', 'ყ'), ('Შ', 'შ'), ('Ჩ', 'ჩ'), ('Ც', 'ც'),\n  ('Ძ', 'ძ'), ('Წ', 'წ'), ('Ჭ', 'ჭ'), ('Ხ', 'ხ'),\n  ('Ჯ', 'ჯ'), ('Ჰ', 'ჰ'), ('Ჱ', 'ჱ'), ('Ჲ', 'ჲ'),\n  ('Ჳ', 'ჳ'), ('Ჴ', 'ჴ'), ('Ჵ', 'ჵ'), ('Ჶ', 'ჶ'),\n  ('Ჷ', 'ჷ'), ('Ჸ', 'ჸ'), ('Ჹ', 'ჹ'), ('Ჺ', 'ჺ'),\n  ('Ჽ', 'ჽ'), ('Ჾ', 'ჾ'), ('Ჿ', 'ჿ'), ('Ḁ', 'ḁ'),\n  ('Ḃ', 'ḃ'), ('Ḅ', 'ḅ'), ('Ḇ', 'ḇ'), ('Ḉ', 'ḉ'),\n  ('Ḋ', 'ḋ'), ('Ḍ', 'ḍ'), ('Ḏ', 'ḏ'), ('Ḑ', 'ḑ'),\n  ('Ḓ', 'ḓ'), ('Ḕ', 'ḕ'), ('Ḗ', 'ḗ'), ('Ḙ', 'ḙ'),\n  ('Ḛ', 'ḛ'), ('Ḝ', 'ḝ'), ('Ḟ', 'ḟ'), ('Ḡ', 'ḡ'),\n  ('Ḣ', 'ḣ'), ('Ḥ', 'ḥ'), ('Ḧ', 'ḧ'), ('Ḩ', 'ḩ'),\n  ('Ḫ', 'ḫ'), ('Ḭ', 'ḭ'), ('Ḯ', 'ḯ'), ('Ḱ', 'ḱ'),\n  ('Ḳ', 'ḳ'), ('Ḵ', 'ḵ'), ('Ḷ', 'ḷ'), ('Ḹ', 'ḹ'),\n  ('Ḻ', 'ḻ'), ('Ḽ', 'ḽ'), ('Ḿ', 'ḿ'), ('Ṁ', 'ṁ'),\n  ('Ṃ', 'ṃ'), ('Ṅ', 'ṅ'), ('Ṇ', 'ṇ'), ('Ṉ', 'ṉ'),\n  ('Ṋ', 'ṋ'), ('Ṍ', 'ṍ'), ('Ṏ', 'ṏ'), ('Ṑ', 'ṑ'),\n  ('Ṓ', 'ṓ'), ('Ṕ', 'ṕ'), ('Ṗ', 'ṗ'), ('Ṙ', 'ṙ'),\n  ('Ṛ', 'ṛ'), ('Ṝ', 'ṝ'), ('Ṟ', 'ṟ'), ('Ṡ', 'ṡ'),\n  ('Ṣ', 'ṣ'), ('Ṥ', 'ṥ'), ('Ṧ', 'ṧ'), ('Ṩ', 'ṩ'),\n  ('Ṫ', 'ṫ'), ('Ṭ', 'ṭ'), ('Ṯ', 'ṯ'), ('Ṱ', 'ṱ'),\n  ('Ṳ', 'ṳ'), ('Ṵ', 'ṵ'), ('Ṷ', 'ṷ'), ('Ṹ', 'ṹ'),\n  ('Ṻ', 'ṻ'), ('Ṽ', 'ṽ'), ('Ṿ', 'ṿ'), ('Ẁ', 'ẁ'),\n  ('Ẃ', 'ẃ'), ('Ẅ', 'ẅ'), ('Ẇ', 'ẇ'), ('Ẉ', 'ẉ'),\n  ('Ẋ', 'ẋ'), ('Ẍ', 'ẍ'), ('Ẏ', 'ẏ'), ('Ẑ', 'ẑ'),\n  ('Ẓ', 'ẓ'), ('Ẕ', 'ẕ'), ('ẛ', 'ṡ'), ('ẞ', 'ß'),\n  ('Ạ', 'ạ'), ('Ả', 'ả'), ('Ấ', 'ấ'), ('Ầ', 'ầ'),\n  ('Ẩ', 'ẩ'), ('Ẫ', 'ẫ'), ('Ậ', 'ậ'), ('Ắ', 'ắ'),\n  ('Ằ', 'ằ'), ('Ẳ', 'ẳ'), ('Ẵ', 'ẵ'), ('Ặ', 'ặ'),\n  ('Ẹ', 'ẹ'), ('Ẻ', 'ẻ'), ('Ẽ', 'ẽ'), ('Ế', 'ế'),\n  ('Ề', 'ề'), ('Ể', 'ể'), ('Ễ', 'ễ'), ('Ệ', 'ệ'),\n  ('Ỉ', 'ỉ'), ('Ị', 'ị'), ('Ọ', 'ọ'), ('Ỏ', 'ỏ'),\n  ('Ố', 'ố'), ('Ồ', 'ồ'), ('Ổ', 'ổ'), ('Ỗ', 'ỗ'),\n  ('Ộ', 'ộ'), ('Ớ', 'ớ'), ('Ờ', 'ờ'), ('Ở', 'ở'),\n  ('Ỡ', 'ỡ'), ('Ợ', 'ợ'), ('Ụ', 'ụ'), ('Ủ', 'ủ'),\n  ('Ứ', 'ứ'), ('Ừ', 'ừ'), ('Ử', 'ử'), ('Ữ', 'ữ'),\n  ('Ự', 'ự'), ('Ỳ', 'ỳ'), ('Ỵ', 'ỵ'), ('Ỷ', 'ỷ'),\n  ('Ỹ', 'ỹ'), ('Ỻ', 'ỻ'), ('Ỽ', 'ỽ'), ('Ỿ', 'ỿ'),\n  ('Ἀ', 'ἀ'), ('Ἁ', 'ἁ'), ('Ἂ', 'ἂ'), ('Ἃ', 'ἃ'),\n  ('Ἄ', 'ἄ'), ('Ἅ', 'ἅ'), ('Ἆ', 'ἆ'), ('Ἇ', 'ἇ'),\n  ('Ἐ', 'ἐ'), ('Ἑ', 'ἑ'), ('Ἒ', 'ἒ'), ('Ἓ', 'ἓ'),\n  ('Ἔ', 'ἔ'), ('Ἕ', 'ἕ'), ('Ἠ', 'ἠ'), ('Ἡ', 'ἡ'),\n  ('Ἢ', 'ἢ'), ('Ἣ', 'ἣ'), ('Ἤ', 'ἤ'), ('Ἥ', 'ἥ'),\n  ('Ἦ', 'ἦ'), ('Ἧ', 'ἧ'), ('Ἰ', 'ἰ'), ('Ἱ', 'ἱ'),\n  ('Ἲ', 'ἲ'), ('Ἳ', 'ἳ'), ('Ἴ', 'ἴ'), ('Ἵ', 'ἵ'),\n  ('Ἶ', 'ἶ'), ('Ἷ', 'ἷ'), ('Ὀ', 'ὀ'), ('Ὁ', 'ὁ'),\n  ('Ὂ', 'ὂ'), ('Ὃ', 'ὃ'), ('Ὄ', 'ὄ'), ('Ὅ', 'ὅ'),\n  ('Ὑ', 'ὑ'), ('Ὓ', 'ὓ'), ('Ὕ', 'ὕ'), ('Ὗ', 'ὗ'),\n  ('Ὠ', 'ὠ'), ('Ὡ', 'ὡ'), ('Ὢ', 'ὢ'), ('Ὣ', 'ὣ'),\n  ('Ὤ', 'ὤ'), ('Ὥ', 'ὥ'), ('Ὦ', 'ὦ'), ('Ὧ', 'ὧ'),\n  ('ᾈ', 'ᾀ'), ('ᾉ', 'ᾁ'), ('ᾊ', 'ᾂ'), ('ᾋ', 'ᾃ'),\n  ('ᾌ', 'ᾄ'), ('ᾍ', 'ᾅ'), ('ᾎ', 'ᾆ'), ('ᾏ', 'ᾇ'),\n  ('ᾘ', 'ᾐ'), ('ᾙ', 'ᾑ'), ('ᾚ', 'ᾒ'), ('ᾛ', 'ᾓ'),\n  ('ᾜ', 'ᾔ'), ('ᾝ', 'ᾕ'), ('ᾞ', 'ᾖ'), ('ᾟ', 'ᾗ'),\n  ('ᾨ', 'ᾠ'), ('ᾩ', 'ᾡ'), ('ᾪ', 'ᾢ'), ('ᾫ', 'ᾣ'),\n  ('ᾬ', 'ᾤ'), ('ᾭ', 'ᾥ'), ('ᾮ', 'ᾦ'), ('ᾯ', 'ᾧ'),\n  ('Ᾰ', 'ᾰ'), ('Ᾱ', 'ᾱ'), ('Ὰ', 'ὰ'), ('Ά', 'ά'),\n  ('ᾼ', 'ᾳ'), ('ι', 'ι'), ('Ὲ', 'ὲ'), ('Έ', 'έ'),\n  ('Ὴ', 'ὴ'), ('Ή', 'ή'), ('ῌ', 'ῃ'), ('Ῐ', 'ῐ'),\n  ('Ῑ', 'ῑ'), ('Ὶ', 'ὶ'), ('Ί', 'ί'), ('Ῠ', 'ῠ'),\n  ('Ῡ', 'ῡ'), ('Ὺ', 'ὺ'), ('Ύ', 'ύ'), ('Ῥ', 'ῥ'),\n  ('Ὸ', 'ὸ'), ('Ό', 'ό'), ('Ὼ', 'ὼ'), ('Ώ', 'ώ'),\n  ('ῼ', 'ῳ'), ('Ω', 'ω'), ('K', 'k'), ('Å', 'å'), ('Ⅎ', 'ⅎ'),\n  ('Ⅰ', 'ⅰ'), ('Ⅱ', 'ⅱ'), ('Ⅲ', 'ⅲ'), ('Ⅳ', 'ⅳ'),\n  ('Ⅴ', 'ⅴ'), ('Ⅵ', 'ⅵ'), ('Ⅶ', 'ⅶ'), ('Ⅷ', 'ⅷ'),\n  ('Ⅸ', 'ⅸ'), ('Ⅹ', 'ⅹ'), ('Ⅺ', 'ⅺ'), ('Ⅻ', 'ⅻ'),\n  ('Ⅼ', 'ⅼ'), ('Ⅽ', 'ⅽ'), ('Ⅾ', 'ⅾ'), ('Ⅿ', 'ⅿ'),\n  ('Ↄ', 'ↄ'), ('Ⓐ', 'ⓐ'), ('Ⓑ', 'ⓑ'), ('Ⓒ', 'ⓒ'),\n  ('Ⓓ', 'ⓓ'), ('Ⓔ', 'ⓔ'), ('Ⓕ', 'ⓕ'), ('Ⓖ', 'ⓖ'),\n  ('Ⓗ', 'ⓗ'), ('Ⓘ', 'ⓘ'), ('Ⓙ', 'ⓙ'), ('Ⓚ', 'ⓚ'),\n  ('Ⓛ', 'ⓛ'), ('Ⓜ', 'ⓜ'), ('Ⓝ', 'ⓝ'), ('Ⓞ', 'ⓞ'),\n  ('Ⓟ', 'ⓟ'), ('Ⓠ', 'ⓠ'), ('Ⓡ', 'ⓡ'), ('Ⓢ', 'ⓢ'),\n  ('Ⓣ', 'ⓣ'), ('Ⓤ', 'ⓤ'), ('Ⓥ', 'ⓥ'), ('Ⓦ', 'ⓦ'),\n  ('Ⓧ', 'ⓧ'), ('Ⓨ', 'ⓨ'), ('Ⓩ', 'ⓩ'), ('Ⰰ', 'ⰰ'),\n  ('Ⰱ', 'ⰱ'), ('Ⰲ', 'ⰲ'), ('Ⰳ', 'ⰳ'), ('Ⰴ', 'ⰴ'),\n  ('Ⰵ', 'ⰵ'), ('Ⰶ', 'ⰶ'), ('Ⰷ', 'ⰷ'), ('Ⰸ', 'ⰸ'),\n  ('Ⰹ', 'ⰹ'), ('Ⰺ', 'ⰺ'), ('Ⰻ', 'ⰻ'), ('Ⰼ', 'ⰼ'),\n  ('Ⰽ', 'ⰽ'), ('Ⰾ', 'ⰾ'), ('Ⰿ', 'ⰿ'), ('Ⱀ', 'ⱀ'),\n  ('Ⱁ', 'ⱁ'), ('Ⱂ', 'ⱂ'), ('Ⱃ', 'ⱃ'), ('Ⱄ', 'ⱄ'),\n  ('Ⱅ', 'ⱅ'), ('Ⱆ', 'ⱆ'), ('Ⱇ', 'ⱇ'), ('Ⱈ', 'ⱈ'),\n  ('Ⱉ', 'ⱉ'), ('Ⱊ', 'ⱊ'), ('Ⱋ', 'ⱋ'), ('Ⱌ', 'ⱌ'),\n  ('Ⱍ', 'ⱍ'), ('Ⱎ', 'ⱎ'), ('Ⱏ', 'ⱏ'), ('Ⱐ', 'ⱐ'),\n  ('Ⱑ', 'ⱑ'), ('Ⱒ', 'ⱒ'), ('Ⱓ', 'ⱓ'), ('Ⱔ', 'ⱔ'),\n  ('Ⱕ', 'ⱕ'), ('Ⱖ', 'ⱖ'), ('Ⱗ', 'ⱗ'), ('Ⱘ', 'ⱘ'),\n  ('Ⱙ', 'ⱙ'), ('Ⱚ', 'ⱚ'), ('Ⱛ', 'ⱛ'), ('Ⱜ', 'ⱜ'),\n  ('Ⱝ', 'ⱝ'), ('Ⱞ', 'ⱞ'), ('Ⱟ', 'ⱟ'), ('Ⱡ', 'ⱡ'),\n  ('Ɫ', 'ɫ'), ('Ᵽ', 'ᵽ'), ('Ɽ', 'ɽ'), ('Ⱨ', 'ⱨ'),\n  ('Ⱪ', 'ⱪ'), ('Ⱬ', 'ⱬ'), ('Ɑ', 'ɑ'), ('Ɱ', 'ɱ'), ('Ɐ', 'ɐ'),\n  ('Ɒ', 'ɒ'), ('Ⱳ', 'ⱳ'), ('Ⱶ', 'ⱶ'), ('Ȿ', 'ȿ'), ('Ɀ', 'ɀ'),\n  ('Ⲁ', 'ⲁ'), ('Ⲃ', 'ⲃ'), ('Ⲅ', 'ⲅ'), ('Ⲇ', 'ⲇ'),\n  ('Ⲉ', 'ⲉ'), ('Ⲋ', 'ⲋ'), ('Ⲍ', 'ⲍ'), ('Ⲏ', 'ⲏ'),\n  ('Ⲑ', 'ⲑ'), ('Ⲓ', 'ⲓ'), ('Ⲕ', 'ⲕ'), ('Ⲗ', 'ⲗ'),\n  ('Ⲙ', 'ⲙ'), ('Ⲛ', 'ⲛ'), ('Ⲝ', 'ⲝ'), ('Ⲟ', 'ⲟ'),\n  ('Ⲡ', 'ⲡ'), ('Ⲣ', 'ⲣ'), ('Ⲥ', 'ⲥ'), ('Ⲧ', 'ⲧ'),\n  ('Ⲩ', 'ⲩ'), ('Ⲫ', 'ⲫ'), ('Ⲭ', 'ⲭ'), ('Ⲯ', 'ⲯ'),\n  ('Ⲱ', 'ⲱ'), ('Ⲳ', 'ⲳ'), ('Ⲵ', 'ⲵ'), ('Ⲷ', 'ⲷ'),\n  ('Ⲹ', 'ⲹ'), ('Ⲻ', 'ⲻ'), ('Ⲽ', 'ⲽ'), ('Ⲿ', 'ⲿ'),\n  ('Ⳁ', 'ⳁ'), ('Ⳃ', 'ⳃ'), ('Ⳅ', 'ⳅ'), ('Ⳇ', 'ⳇ'),\n  ('Ⳉ', 'ⳉ'), ('Ⳋ', 'ⳋ'), ('Ⳍ', 'ⳍ'), ('Ⳏ', 'ⳏ'),\n  ('Ⳑ', 'ⳑ'), ('Ⳓ', 'ⳓ'), ('Ⳕ', 'ⳕ'), ('Ⳗ', 'ⳗ'),\n  ('Ⳙ', 'ⳙ'), ('Ⳛ', 'ⳛ'), ('Ⳝ', 'ⳝ'), ('Ⳟ', 'ⳟ'),\n  ('Ⳡ', 'ⳡ'), ('Ⳣ', 'ⳣ'), ('Ⳬ', 'ⳬ'), ('Ⳮ', 'ⳮ'),\n  ('Ⳳ', 'ⳳ'), ('Ꙁ', 'ꙁ'), ('Ꙃ', 'ꙃ'), ('Ꙅ', 'ꙅ'),\n  ('Ꙇ', 'ꙇ'), ('Ꙉ', 'ꙉ'), ('Ꙋ', 'ꙋ'), ('Ꙍ', 'ꙍ'),\n  ('Ꙏ', 'ꙏ'), ('Ꙑ', 'ꙑ'), ('Ꙓ', 'ꙓ'), ('Ꙕ', 'ꙕ'),\n  ('Ꙗ', 'ꙗ'), ('Ꙙ', 'ꙙ'), ('Ꙛ', 'ꙛ'), ('Ꙝ', 'ꙝ'),\n  ('Ꙟ', 'ꙟ'), ('Ꙡ', 'ꙡ'), ('Ꙣ', 'ꙣ'), ('Ꙥ', 'ꙥ'),\n  ('Ꙧ', 'ꙧ'), ('Ꙩ', 'ꙩ'), ('Ꙫ', 'ꙫ'), ('Ꙭ', 'ꙭ'),\n  ('Ꚁ', 'ꚁ'), ('Ꚃ', 'ꚃ'), ('Ꚅ', 'ꚅ'), ('Ꚇ', 'ꚇ'),\n  ('Ꚉ', 'ꚉ'), ('Ꚋ', 'ꚋ'), ('Ꚍ', 'ꚍ'), ('Ꚏ', 'ꚏ'),\n  ('Ꚑ', 'ꚑ'), ('Ꚓ', 'ꚓ'), ('Ꚕ', 'ꚕ'), ('Ꚗ', 'ꚗ'),\n  ('Ꚙ', 'ꚙ'), ('Ꚛ', 'ꚛ'), ('Ꜣ', 'ꜣ'), ('Ꜥ', 'ꜥ'),\n  ('Ꜧ', 'ꜧ'), ('Ꜩ', 'ꜩ'), ('Ꜫ', 'ꜫ'), ('Ꜭ', 'ꜭ'),\n  ('Ꜯ', 'ꜯ'), ('Ꜳ', 'ꜳ'), ('Ꜵ', 'ꜵ'), ('Ꜷ', 'ꜷ'),\n  ('Ꜹ', 'ꜹ'), ('Ꜻ', 'ꜻ'), ('Ꜽ', 'ꜽ'), ('Ꜿ', 'ꜿ'),\n  ('Ꝁ', 'ꝁ'), ('Ꝃ', 'ꝃ'), ('Ꝅ', 'ꝅ'), ('Ꝇ', 'ꝇ'),\n  ('Ꝉ', 'ꝉ'), ('Ꝋ', 'ꝋ'), ('Ꝍ', 'ꝍ'), ('Ꝏ', 'ꝏ'),\n  ('Ꝑ', 'ꝑ'), ('Ꝓ', 'ꝓ'), ('Ꝕ', 'ꝕ'), ('Ꝗ', 'ꝗ'),\n  ('Ꝙ', 'ꝙ'), ('Ꝛ', 'ꝛ'), ('Ꝝ', 'ꝝ'), ('Ꝟ', 'ꝟ'),\n  ('Ꝡ', 'ꝡ'), ('Ꝣ', 'ꝣ'), ('Ꝥ', 'ꝥ'), ('Ꝧ', 'ꝧ'),\n  ('Ꝩ', 'ꝩ'), ('Ꝫ', 'ꝫ'), ('Ꝭ', 'ꝭ'), ('Ꝯ', 'ꝯ'),\n  ('Ꝺ', 'ꝺ'), ('Ꝼ', 'ꝼ'), ('Ᵹ', 'ᵹ'), ('Ꝿ', 'ꝿ'),\n  ('Ꞁ', 'ꞁ'), ('Ꞃ', 'ꞃ'), ('Ꞅ', 'ꞅ'), ('Ꞇ', 'ꞇ'),\n  ('Ꞌ', 'ꞌ'), ('Ɥ', 'ɥ'), ('Ꞑ', 'ꞑ'), ('Ꞓ', 'ꞓ'),\n  ('Ꞗ', 'ꞗ'), ('Ꞙ', 'ꞙ'), ('Ꞛ', 'ꞛ'), ('Ꞝ', 'ꞝ'),\n  ('Ꞟ', 'ꞟ'), ('Ꞡ', 'ꞡ'), ('Ꞣ', 'ꞣ'), ('Ꞥ', 'ꞥ'),\n  ('Ꞧ', 'ꞧ'), ('Ꞩ', 'ꞩ'), ('Ɦ', 'ɦ'), ('Ɜ', 'ɜ'), ('Ɡ', 'ɡ'),\n  ('Ɬ', 'ɬ'), ('Ɪ', 'ɪ'), ('Ʞ', 'ʞ'), ('Ʇ', 'ʇ'), ('Ʝ', 'ʝ'),\n  ('Ꭓ', 'ꭓ'), ('Ꞵ', 'ꞵ'), ('Ꞷ', 'ꞷ'), ('Ꞹ', 'ꞹ'),\n  ('Ꞻ', 'ꞻ'), ('Ꞽ', 'ꞽ'), ('Ꞿ', 'ꞿ'), ('Ꟁ', 'ꟁ'),\n  ('Ꟃ', 'ꟃ'), ('Ꞔ', 'ꞔ'), ('Ʂ', 'ʂ'), ('Ᶎ', 'ᶎ'),\n  ('Ꟈ', 'ꟈ'), ('Ꟊ', 'ꟊ'), ('Ꟑ', 'ꟑ'), ('Ꟗ', 'ꟗ'),\n  ('Ꟙ', 'ꟙ'), ('Ꟶ', 'ꟶ'), ('ꭰ', 'Ꭰ'), ('ꭱ', 'Ꭱ'),\n  ('ꭲ', 'Ꭲ'), ('ꭳ', 'Ꭳ'), ('ꭴ', 'Ꭴ'), ('ꭵ', 'Ꭵ'),\n  ('ꭶ', 'Ꭶ'), ('ꭷ', 'Ꭷ'), ('ꭸ', 'Ꭸ'), ('ꭹ', 'Ꭹ'),\n  ('ꭺ', 'Ꭺ'), ('ꭻ', 'Ꭻ'), ('ꭼ', 'Ꭼ'), ('ꭽ', 'Ꭽ'),\n  ('ꭾ', 'Ꭾ'), ('ꭿ', 'Ꭿ'), ('ꮀ', 'Ꮀ'), ('ꮁ', 'Ꮁ'),\n  ('ꮂ', 'Ꮂ'), ('ꮃ', 'Ꮃ'), ('ꮄ', 'Ꮄ'), ('ꮅ', 'Ꮅ'),\n  ('ꮆ', 'Ꮆ'), ('ꮇ', 'Ꮇ'), ('ꮈ', 'Ꮈ'), ('ꮉ', 'Ꮉ'),\n  ('ꮊ', 'Ꮊ'), ('ꮋ', 'Ꮋ'), ('ꮌ', 'Ꮌ'), ('ꮍ', 'Ꮍ'),\n  ('ꮎ', 'Ꮎ'), ('ꮏ', 'Ꮏ'), ('ꮐ', 'Ꮐ'), ('ꮑ', 'Ꮑ'),\n  ('ꮒ', 'Ꮒ'), ('ꮓ', 'Ꮓ'), ('ꮔ', 'Ꮔ'), ('ꮕ', 'Ꮕ'),\n  ('ꮖ', 'Ꮖ'), ('ꮗ', 'Ꮗ'), ('ꮘ', 'Ꮘ'), ('ꮙ', 'Ꮙ'),\n  ('ꮚ', 'Ꮚ'), ('ꮛ', 'Ꮛ'), ('ꮜ', 'Ꮜ'), ('ꮝ', 'Ꮝ'),\n  ('ꮞ', 'Ꮞ'), ('ꮟ', 'Ꮟ'), ('ꮠ', 'Ꮠ'), ('ꮡ', 'Ꮡ'),\n  ('ꮢ', 'Ꮢ'), ('ꮣ', 'Ꮣ'), ('ꮤ', 'Ꮤ'), ('ꮥ', 'Ꮥ'),\n  ('ꮦ', 'Ꮦ'), ('ꮧ', 'Ꮧ'), ('ꮨ', 'Ꮨ'), ('ꮩ', 'Ꮩ'),\n  ('ꮪ', 'Ꮪ'), ('ꮫ', 'Ꮫ'), ('ꮬ', 'Ꮬ'), ('ꮭ', 'Ꮭ'),\n  ('ꮮ', 'Ꮮ'), ('ꮯ', 'Ꮯ'), ('ꮰ', 'Ꮰ'), ('ꮱ', 'Ꮱ'),\n  ('ꮲ', 'Ꮲ'), ('ꮳ', 'Ꮳ'), ('ꮴ', 'Ꮴ'), ('ꮵ', 'Ꮵ'),\n  ('ꮶ', 'Ꮶ'), ('ꮷ', 'Ꮷ'), ('ꮸ', 'Ꮸ'), ('ꮹ', 'Ꮹ'),\n  ('ꮺ', 'Ꮺ'), ('ꮻ', 'Ꮻ'), ('ꮼ', 'Ꮼ'), ('ꮽ', 'Ꮽ'),\n  ('ꮾ', 'Ꮾ'), ('ꮿ', 'Ꮿ'), ('Ａ', 'ａ'), ('Ｂ', 'ｂ'),\n  ('Ｃ', 'ｃ'), ('Ｄ', 'ｄ'), ('Ｅ', 'ｅ'), ('Ｆ', 'ｆ'),\n  ('Ｇ', 'ｇ'), ('Ｈ', 'ｈ'), ('Ｉ', 'ｉ'), ('Ｊ', 'ｊ'),\n  ('Ｋ', 'ｋ'), ('Ｌ', 'ｌ'), ('Ｍ', 'ｍ'), ('Ｎ', 'ｎ'),\n  ('Ｏ', 'ｏ'), ('Ｐ', 'ｐ'), ('Ｑ', 'ｑ'), ('Ｒ', 'ｒ'),\n  ('Ｓ', 'ｓ'), ('Ｔ', 'ｔ'), ('Ｕ', 'ｕ'), ('Ｖ', 'ｖ'),\n  ('Ｗ', 'ｗ'), ('Ｘ', 'ｘ'), ('Ｙ', 'ｙ'), ('Ｚ', 'ｚ'),\n  ('𐐀', '𐐨'), ('𐐁', '𐐩'), ('𐐂', '𐐪'), ('𐐃', '𐐫'),\n  ('𐐄', '𐐬'), ('𐐅', '𐐭'), ('𐐆', '𐐮'), ('𐐇', '𐐯'),\n  ('𐐈', '𐐰'), ('𐐉', '𐐱'), ('𐐊', '𐐲'), ('𐐋', '𐐳'),\n  ('𐐌', '𐐴'), ('𐐍', '𐐵'), ('𐐎', '𐐶'), ('𐐏', '𐐷'),\n  ('𐐐', '𐐸'), ('𐐑', '𐐹'), ('𐐒', '𐐺'), ('𐐓', '𐐻'),\n  ('𐐔', '𐐼'), ('𐐕', '𐐽'), ('𐐖', '𐐾'), ('𐐗', '𐐿'),\n  ('𐐘', '𐑀'), ('𐐙', '𐑁'), ('𐐚', '𐑂'), ('𐐛', '𐑃'),\n  ('𐐜', '𐑄'), ('𐐝', '𐑅'), ('𐐞', '𐑆'), ('𐐟', '𐑇'),\n  ('𐐠', '𐑈'), ('𐐡', '𐑉'), ('𐐢', '𐑊'), ('𐐣', '𐑋'),\n  ('𐐤', '𐑌'), ('𐐥', '𐑍'), ('𐐦', '𐑎'), ('𐐧', '𐑏'),\n  ('𐒰', '𐓘'), ('𐒱', '𐓙'), ('𐒲', '𐓚'), ('𐒳', '𐓛'),\n  ('𐒴', '𐓜'), ('𐒵', '𐓝'), ('𐒶', '𐓞'), ('𐒷', '𐓟'),\n  ('𐒸', '𐓠'), ('𐒹', '𐓡'), ('𐒺', '𐓢'), ('𐒻', '𐓣'),\n  ('𐒼', '𐓤'), ('𐒽', '𐓥'), ('𐒾', '𐓦'), ('𐒿', '𐓧'),\n  ('𐓀', '𐓨'), ('𐓁', '𐓩'), ('𐓂', '𐓪'), ('𐓃', '𐓫'),\n  ('𐓄', '𐓬'), ('𐓅', '𐓭'), ('𐓆', '𐓮'), ('𐓇', '𐓯'),\n  ('𐓈', '𐓰'), ('𐓉', '𐓱'), ('𐓊', '𐓲'), ('𐓋', '𐓳'),\n  ('𐓌', '𐓴'), ('𐓍', '𐓵'), ('𐓎', '𐓶'), ('𐓏', '𐓷'),\n  ('𐓐', '𐓸'), ('𐓑', '𐓹'), ('𐓒', '𐓺'), ('𐓓', '𐓻'),\n  ('𐕰', '𐖗'), ('𐕱', '𐖘'), ('𐕲', '𐖙'), ('𐕳', '𐖚'),\n  ('𐕴', '𐖛'), ('𐕵', '𐖜'), ('𐕶', '𐖝'), ('𐕷', '𐖞'),\n  ('𐕸', '𐖟'), ('𐕹', '𐖠'), ('𐕺', '𐖡'), ('𐕼', '𐖣'),\n  ('𐕽', '𐖤'), ('𐕾', '𐖥'), ('𐕿', '𐖦'), ('𐖀', '𐖧'),\n  ('𐖁', '𐖨'), ('𐖂', '𐖩'), ('𐖃', '𐖪'), ('𐖄', '𐖫'),\n  ('𐖅', '𐖬'), ('𐖆', '𐖭'), ('𐖇', '𐖮'), ('𐖈', '𐖯'),\n  ('𐖉', '𐖰'), ('𐖊', '𐖱'), ('𐖌', '𐖳'), ('𐖍', '𐖴'),\n  ('𐖎', '𐖵'), ('𐖏', '𐖶'), ('𐖐', '𐖷'), ('𐖑', '𐖸'),\n  ('𐖒', '𐖹'), ('𐖔', '𐖻'), ('𐖕', '𐖼'), ('𐲀', '𐳀'),\n  ('𐲁', '𐳁'), ('𐲂', '𐳂'), ('𐲃', '𐳃'), ('𐲄', '𐳄'),\n  ('𐲅', '𐳅'), ('𐲆', '𐳆'), ('𐲇', '𐳇'), ('𐲈', '𐳈'),\n  ('𐲉', '𐳉'), ('𐲊', '𐳊'), ('𐲋', '𐳋'), ('𐲌', '𐳌'),\n  ('𐲍', '𐳍'), ('𐲎', '𐳎'), ('𐲏', '𐳏'), ('𐲐', '𐳐'),\n  ('𐲑', '𐳑'), ('𐲒', '𐳒'), ('𐲓', '𐳓'), ('𐲔', '𐳔'),\n  ('𐲕', '𐳕'), ('𐲖', '𐳖'), ('𐲗', '𐳗'), ('𐲘', '𐳘'),\n  ('𐲙', '𐳙'), ('𐲚', '𐳚'), ('𐲛', '𐳛'), ('𐲜', '𐳜'),\n  ('𐲝', '𐳝'), ('𐲞', '𐳞'), ('𐲟', '𐳟'), ('𐲠', '𐳠'),\n  ('𐲡', '𐳡'), ('𐲢', '𐳢'), ('𐲣', '𐳣'), ('𐲤', '𐳤'),\n  ('𐲥', '𐳥'), ('𐲦', '𐳦'), ('𐲧', '𐳧'), ('𐲨', '𐳨'),\n  ('𐲩', '𐳩'), ('𐲪', '𐳪'), ('𐲫', '𐳫'), ('𐲬', '𐳬'),\n  ('𐲭', '𐳭'), ('𐲮', '𐳮'), ('𐲯', '𐳯'), ('𐲰', '𐳰'),\n  ('𐲱', '𐳱'), ('𐲲', '𐳲'), ('𑢠', '𑣀'), ('𑢡', '𑣁'),\n  ('𑢢', '𑣂'), ('𑢣', '𑣃'), ('𑢤', '𑣄'), ('𑢥', '𑣅'),\n  ('𑢦', '𑣆'), ('𑢧', '𑣇'), ('𑢨', '𑣈'), ('𑢩', '𑣉'),\n  ('𑢪', '𑣊'), ('𑢫', '𑣋'), ('𑢬', '𑣌'), ('𑢭', '𑣍'),\n  ('𑢮', '𑣎'), ('𑢯', '𑣏'), ('𑢰', '𑣐'), ('𑢱', '𑣑'),\n  ('𑢲', '𑣒'), ('𑢳', '𑣓'), ('𑢴', '𑣔'), ('𑢵', '𑣕'),\n  ('𑢶', '𑣖'), ('𑢷', '𑣗'), ('𑢸', '𑣘'), ('𑢹', '𑣙'),\n  ('𑢺', '𑣚'), ('𑢻', '𑣛'), ('𑢼', '𑣜'), ('𑢽', '𑣝'),\n  ('𑢾', '𑣞'), ('𑢿', '𑣟'), ('𖹀', '𖹠'), ('𖹁', '𖹡'),\n  ('𖹂', '𖹢'), ('𖹃', '𖹣'), ('𖹄', '𖹤'), ('𖹅', '𖹥'),\n  ('𖹆', '𖹦'), ('𖹇', '𖹧'), ('𖹈', '𖹨'), ('𖹉', '𖹩'),\n  ('𖹊', '𖹪'), ('𖹋', '𖹫'), ('𖹌', '𖹬'), ('𖹍', '𖹭'),\n  ('𖹎', '𖹮'), ('𖹏', '𖹯'), ('𖹐', '𖹰'), ('𖹑', '𖹱'),\n  ('𖹒', '𖹲'), ('𖹓', '𖹳'), ('𖹔', '𖹴'), ('𖹕', '𖹵'),\n  ('𖹖', '𖹶'), ('𖹗', '𖹷'), ('𖹘', '𖹸'), ('𖹙', '𖹹'),\n  ('𖹚', '𖹺'), ('𖹛', '𖹻'), ('𖹜', '𖹼'), ('𖹝', '𖹽'),\n  ('𖹞', '𖹾'), ('𖹟', '𖹿'), ('𞤀', '𞤢'), ('𞤁', '𞤣'),\n  ('𞤂', '𞤤'), ('𞤃', '𞤥'), ('𞤄', '𞤦'), ('𞤅', '𞤧'),\n  ('𞤆', '𞤨'), ('𞤇', '𞤩'), ('𞤈', '𞤪'), ('𞤉', '𞤫'),\n  ('𞤊', '𞤬'), ('𞤋', '𞤭'), ('𞤌', '𞤮'), ('𞤍', '𞤯'),\n  ('𞤎', '𞤰'), ('𞤏', '𞤱'), ('𞤐', '𞤲'), ('𞤑', '𞤳'),\n  ('𞤒', '𞤴'), ('𞤓', '𞤵'), ('𞤔', '𞤶'), ('𞤕', '𞤷'),\n  ('𞤖', '𞤸'), ('𞤗', '𞤹'), ('𞤘', '𞤺'), ('𞤙', '𞤻'),\n  ('𞤚', '𞤼'), ('𞤛', '𞤽'), ('𞤜', '𞤾'), ('𞤝', '𞤿'),\n  ('𞤞', '𞥀'), ('𞤟', '𞥁'), ('𞤠', '𞥂'), ('𞤡', '𞥃'),\n];\n"
  },
  {
    "path": "crates/atuin-nucleo/matcher/src/chars/normalize.rs",
    "content": "/// Normalize a Unicode character by converting Latin characters which are variants\n/// of ASCII characters to their latin equivalent.\n///\n/// Note that this method acts on single `char`s: if you want to perform full normalization, you\n/// should first split on graphemes, and then normalize each grapheme by normalizing the first\n/// `char` in the grapheme.\n///\n/// If a character does not normalize to a single ASCII character, no normalization is performed.\n///\n/// This performs normalization within the following Unicode blocks:\n///\n/// - [Latin-1 Supplement](https://en.wikipedia.org/wiki/Latin-1_Supplement)\n/// - [Latin Extended-A](https://en.wikipedia.org/wiki/Latin_Extended-A)\n/// - [Latin Extended-B](https://en.wikipedia.org/wiki/Latin_Extended-B)\n/// - [Latin Extended Additional](https://en.wikipedia.org/wiki/Latin_Extended_Additional)\n/// - [Superscripts and Subscripts](https://en.wikipedia.org/wiki/Superscripts_and_Subscripts)\n///\n/// If the character does not fall in this block, it is not normalized.\n///\n/// # Example\n/// ```\n/// # use atuin_nucleo_matcher::chars::normalize;\n/// assert_eq!(normalize('ä'), 'a');\n/// assert_eq!(normalize('Æ'), 'Æ');\n/// assert_eq!(normalize('ữ'), 'u');\n/// ```\npub fn normalize(c: char) -> char {\n    // outside checked blocks\n    if c < '\\u{a0}' || c >= '\\u{20A0}' {\n        return c;\n    }\n    // Latin-1 Supplement, Extended-A, Extended-B\n    if c <= '\\u{29f}' {\n        return LATIN_1AB[c as usize - '\\u{a0}' as usize];\n    }\n    // between blocks\n    if c < '\\u{1e00}' {\n        return c;\n    }\n    // Latin Extended Additional\n    if c <= '\\u{1eff}' {\n        return LATIN_EXTENDED_ADDITIONAL[c as usize - '\\u{1e00}' as usize];\n    }\n    // between blocks\n    if c < '\\u{2070}' {\n        return c;\n    }\n    // Superscripts and subscripts\n    SUPERSCRIPTS_AND_SUBSCRIPTS[c as usize - '\\u{2070}' as usize]\n}\n\n/// A char array corresponding to the following contiguous Unicode blocks:\n///\n/// - [Latin-1 Supplement](https://en.wikipedia.org/wiki/Latin-1_Supplement)\n/// - [Latin Extended-A](https://en.wikipedia.org/wiki/Latin_Extended-A)\n/// - [Latin Extended-B](https://en.wikipedia.org/wiki/Latin_Extended-B)\n///\n/// This covers the range `'\\u{a0}'..='\\u{29f}'`.\nstatic LATIN_1AB: [char; 512] = [\n    '\\u{a0}', // invisible NON BREAKING SPACE\n    '!',      // '¡'; '\\u{a1}'\n    '¢',      // '¢'; '\\u{a2}'\n    '£',      // '£'; '\\u{a3}'\n    '¤',      // '¤'; '\\u{a4}'\n    '¥',      // '¥'; '\\u{a5}'\n    '¦',      // '¦'; '\\u{a6}'\n    '§',      // '§'; '\\u{a7}'\n    '¨',      // '¨'; '\\u{a8}'\n    '©',      // '©'; '\\u{a9}'\n    'a',      // 'ª'; '\\u{aa}'\n    '«',      // '«'; '\\u{ab}'\n    '¬',      // '¬'; '\\u{ac}'\n    '\\u{ad}', // invisible SOFT HYPHEN\n    '®',      // '®'; '\\u{ae}'\n    '¯',      // '¯'; '\\u{af}'\n    '°',      // '°'; '\\u{b0}'\n    '±',      // '±'; '\\u{b1}'\n    '2',      // '²'; '\\u{b2}'\n    '3',      // '³'; '\\u{b3}'\n    '´',      // '´'; '\\u{b4}'\n    'µ',      // 'µ'; '\\u{b5}'\n    '¶',      // '¶'; '\\u{b6}'\n    '·',      // '·'; '\\u{b7}'\n    '¸',      // '¸'; '\\u{b8}'\n    '1',      // '¹'; '\\u{b9}'\n    '0',      // 'º'; '\\u{ba}'\n    '»',      // '»'; '\\u{bb}'\n    '¼',      // '¼'; '\\u{bc}'\n    '½',      // '½'; '\\u{bd}'\n    '¾',      // '¾'; '\\u{be}'\n    '?',      // '¿'; '\\u{bf}'\n    'A',      // 'À'; '\\u{c0}'\n    'A',      // 'Á'; '\\u{c1}'\n    'A',      // 'Â'; '\\u{c2}'\n    'A',      // 'Ã'; '\\u{c3}'\n    'A',      // 'Ä'; '\\u{c4}'\n    'A',      // 'Å'; '\\u{c5}'\n    'Æ',      // 'Æ'; '\\u{c6}'\n    'C',      // 'Ç'; '\\u{c7}'\n    'E',      // 'È'; '\\u{c8}'\n    'E',      // 'É'; '\\u{c9}'\n    'E',      // 'Ê'; '\\u{ca}'\n    'E',      // 'Ë'; '\\u{cb}'\n    'I',      // 'Ì'; '\\u{cc}'\n    'I',      // 'Í'; '\\u{cd}'\n    'I',      // 'Î'; '\\u{ce}'\n    'I',      // 'Ï'; '\\u{cf}'\n    'D',      // 'Ð'; '\\u{d0}'\n    'N',      // 'Ñ'; '\\u{d1}'\n    'O',      // 'Ò'; '\\u{d2}'\n    'O',      // 'Ó'; '\\u{d3}'\n    'O',      // 'Ô'; '\\u{d4}'\n    'O',      // 'Õ'; '\\u{d5}'\n    'O',      // 'Ö'; '\\u{d6}'\n    '×',      // '×'; '\\u{d7}'\n    'O',      // 'Ø'; '\\u{d8}'\n    'U',      // 'Ù'; '\\u{d9}'\n    'U',      // 'Ú'; '\\u{da}'\n    'U',      // 'Û'; '\\u{db}'\n    'U',      // 'Ü'; '\\u{dc}'\n    'Y',      // 'Ý'; '\\u{dd}'\n    'Þ',      // 'Þ'; '\\u{de}'\n    's',      // 'ß'; '\\u{df}'\n    'a',      // 'à'; '\\u{e0}'\n    'a',      // 'á'; '\\u{e1}'\n    'a',      // 'â'; '\\u{e2}'\n    'a',      // 'ã'; '\\u{e3}'\n    'a',      // 'ä'; '\\u{e4}'\n    'a',      // 'å'; '\\u{e5}'\n    'æ',      // 'æ'; '\\u{e6}'\n    'c',      // 'ç'; '\\u{e7}'\n    'e',      // 'è'; '\\u{e8}'\n    'e',      // 'é'; '\\u{e9}'\n    'e',      // 'ê'; '\\u{ea}'\n    'e',      // 'ë'; '\\u{eb}'\n    'i',      // 'ì'; '\\u{ec}'\n    'i',      // 'í'; '\\u{ed}'\n    'i',      // 'î'; '\\u{ee}'\n    'i',      // 'ï'; '\\u{ef}'\n    'd',      // 'ð'; '\\u{f0}'\n    'n',      // 'ñ'; '\\u{f1}'\n    'o',      // 'ò'; '\\u{f2}'\n    'o',      // 'ó'; '\\u{f3}'\n    'o',      // 'ô'; '\\u{f4}'\n    'o',      // 'õ'; '\\u{f5}'\n    'o',      // 'ö'; '\\u{f6}'\n    '÷',      // '÷'; '\\u{f7}'\n    'o',      // 'ø'; '\\u{f8}'\n    'u',      // 'ù'; '\\u{f9}'\n    'u',      // 'ú'; '\\u{fa}'\n    'u',      // 'û'; '\\u{fb}'\n    'u',      // 'ü'; '\\u{fc}'\n    'y',      // 'ý'; '\\u{fd}'\n    'þ',      // 'þ'; '\\u{fe}'\n    'y',      // 'ÿ'; '\\u{ff}'\n    'A',      // 'Ā'; '\\u{100}'\n    'a',      // 'ā'; '\\u{101}'\n    'A',      // 'Ă'; '\\u{102}'\n    'a',      // 'ă'; '\\u{103}'\n    'A',      // 'Ą'; '\\u{104}'\n    'a',      // 'ą'; '\\u{105}'\n    'C',      // 'Ć'; '\\u{106}'\n    'c',      // 'ć'; '\\u{107}'\n    'C',      // 'Ĉ'; '\\u{108}'\n    'c',      // 'ĉ'; '\\u{109}'\n    'C',      // 'Ċ'; '\\u{10a}'\n    'c',      // 'ċ'; '\\u{10b}'\n    'C',      // 'Č'; '\\u{10c}'\n    'c',      // 'č'; '\\u{10d}'\n    'D',      // 'Ď'; '\\u{10e}'\n    'd',      // 'ď'; '\\u{10f}'\n    'D',      // 'Đ'; '\\u{110}'\n    'd',      // 'đ'; '\\u{111}'\n    'E',      // 'Ē'; '\\u{112}'\n    'e',      // 'ē'; '\\u{113}'\n    'E',      // 'Ĕ'; '\\u{114}'\n    'e',      // 'ĕ'; '\\u{115}'\n    'E',      // 'Ė'; '\\u{116}'\n    'e',      // 'ė'; '\\u{117}'\n    'E',      // 'Ę'; '\\u{118}'\n    'e',      // 'ę'; '\\u{119}'\n    'E',      // 'Ě'; '\\u{11a}'\n    'e',      // 'ě'; '\\u{11b}'\n    'G',      // 'Ĝ'; '\\u{11c}'\n    'g',      // 'ĝ'; '\\u{11d}'\n    'G',      // 'Ğ'; '\\u{11e}'\n    'g',      // 'ğ'; '\\u{11f}'\n    'G',      // 'Ġ'; '\\u{120}'\n    'g',      // 'ġ'; '\\u{121}'\n    'G',      // 'Ģ'; '\\u{122}'\n    'g',      // 'ģ'; '\\u{123}'\n    'H',      // 'Ĥ'; '\\u{124}'\n    'h',      // 'ĥ'; '\\u{125}'\n    'H',      // 'Ħ'; '\\u{126}'\n    'h',      // 'ħ'; '\\u{127}'\n    'I',      // 'Ĩ'; '\\u{128}'\n    'i',      // 'ĩ'; '\\u{129}'\n    'I',      // 'Ī'; '\\u{12a}'\n    'i',      // 'ī'; '\\u{12b}'\n    'I',      // 'Ĭ'; '\\u{12c}'\n    'i',      // 'ĭ'; '\\u{12d}'\n    'I',      // 'Į'; '\\u{12e}'\n    'i',      // 'į'; '\\u{12f}'\n    'I',      // 'İ'; '\\u{130}'\n    'i',      // 'ı'; '\\u{131}'\n    'Ĳ',      // 'Ĳ'; '\\u{132}'\n    'ĳ',      // 'ĳ'; '\\u{133}'\n    'J',      // 'Ĵ'; '\\u{134}'\n    'j',      // 'ĵ'; '\\u{135}'\n    'K',      // 'Ķ'; '\\u{136}'\n    'k',      // 'ķ'; '\\u{137}'\n    'ĸ',      // 'ĸ'; '\\u{138}'\n    'L',      // 'Ĺ'; '\\u{139}'\n    'l',      // 'ĺ'; '\\u{13a}'\n    'L',      // 'Ļ'; '\\u{13b}'\n    'l',      // 'ļ'; '\\u{13c}'\n    'L',      // 'Ľ'; '\\u{13d}'\n    'l',      // 'ľ'; '\\u{13e}'\n    'L',      // 'Ŀ'; '\\u{13f}'\n    'l',      // 'ŀ'; '\\u{140}'\n    'L',      // 'Ł'; '\\u{141}'\n    'l',      // 'ł'; '\\u{142}'\n    'N',      // 'Ń'; '\\u{143}'\n    'n',      // 'ń'; '\\u{144}'\n    'N',      // 'Ņ'; '\\u{145}'\n    'n',      // 'ņ'; '\\u{146}'\n    'N',      // 'Ň'; '\\u{147}'\n    'n',      // 'ň'; '\\u{148}'\n    'n',      // 'ŉ'; '\\u{149}'\n    'N',      // 'Ŋ'; '\\u{14a}'\n    'n',      // 'ŋ'; '\\u{14b}'\n    'O',      // 'Ō'; '\\u{14c}'\n    'o',      // 'ō'; '\\u{14d}'\n    'O',      // 'Ŏ'; '\\u{14e}'\n    'o',      // 'ŏ'; '\\u{14f}'\n    'O',      // 'Ő'; '\\u{150}'\n    'o',      // 'ő'; '\\u{151}'\n    'Œ',      // 'Œ'; '\\u{152}'\n    'œ',      // 'œ'; '\\u{153}'\n    'R',      // 'Ŕ'; '\\u{154}'\n    'r',      // 'ŕ'; '\\u{155}'\n    'R',      // 'Ŗ'; '\\u{156}'\n    'r',      // 'ŗ'; '\\u{157}'\n    'R',      // 'Ř'; '\\u{158}'\n    'r',      // 'ř'; '\\u{159}'\n    'S',      // 'Ś'; '\\u{15a}'\n    's',      // 'ś'; '\\u{15b}'\n    'S',      // 'Ŝ'; '\\u{15c}'\n    's',      // 'ŝ'; '\\u{15d}'\n    'S',      // 'Ş'; '\\u{15e}'\n    's',      // 'ş'; '\\u{15f}'\n    'S',      // 'Š'; '\\u{160}'\n    's',      // 'š'; '\\u{161}'\n    'T',      // 'Ţ'; '\\u{162}'\n    't',      // 'ţ'; '\\u{163}'\n    'T',      // 'Ť'; '\\u{164}'\n    't',      // 'ť'; '\\u{165}'\n    'T',      // 'Ŧ'; '\\u{166}'\n    't',      // 'ŧ'; '\\u{167}'\n    'U',      // 'Ũ'; '\\u{168}'\n    'u',      // 'ũ'; '\\u{169}'\n    'U',      // 'Ū'; '\\u{16a}'\n    'u',      // 'ū'; '\\u{16b}'\n    'U',      // 'Ŭ'; '\\u{16c}'\n    'u',      // 'ŭ'; '\\u{16d}'\n    'U',      // 'Ů'; '\\u{16e}'\n    'u',      // 'ů'; '\\u{16f}'\n    'U',      // 'Ű'; '\\u{170}'\n    'u',      // 'ű'; '\\u{171}'\n    'U',      // 'Ų'; '\\u{172}'\n    'u',      // 'ų'; '\\u{173}'\n    'W',      // 'Ŵ'; '\\u{174}'\n    'w',      // 'ŵ'; '\\u{175}'\n    'Y',      // 'Ŷ'; '\\u{176}'\n    'y',      // 'ŷ'; '\\u{177}'\n    'Y',      // 'Ÿ'; '\\u{178}'\n    'Z',      // 'Ź'; '\\u{179}'\n    'z',      // 'ź'; '\\u{17a}'\n    'Z',      // 'Ż'; '\\u{17b}'\n    'z',      // 'ż'; '\\u{17c}'\n    'Z',      // 'Ž'; '\\u{17d}'\n    'z',      // 'ž'; '\\u{17e}'\n    's',      // 'ſ'; '\\u{17f}'\n    'b',      // 'ƀ'; '\\u{180}'\n    'B',      // 'Ɓ'; '\\u{181}'\n    'b',      // 'Ƃ'; '\\u{182}'\n    'b',      // 'ƃ'; '\\u{183}'\n    'b',      // 'Ƅ'; '\\u{184}'\n    'ƅ',      // 'ƅ'; '\\u{185}'\n    'O',      // 'Ɔ'; '\\u{186}'\n    'C',      // 'Ƈ'; '\\u{187}'\n    'c',      // 'ƈ'; '\\u{188}'\n    'D',      // 'Ɖ'; '\\u{189}'\n    'D',      // 'Ɗ'; '\\u{18a}'\n    'd',      // 'Ƌ'; '\\u{18b}'\n    'd',      // 'ƌ'; '\\u{18c}'\n    'ƍ',      // 'ƍ'; '\\u{18d}'\n    'E',      // 'Ǝ'; '\\u{18e}'\n    'e',      // 'Ə'; '\\u{18f}'\n    'E',      // 'Ɛ'; '\\u{190}'\n    'F',      // 'Ƒ'; '\\u{191}'\n    'f',      // 'ƒ'; '\\u{192}'\n    'G',      // 'Ɠ'; '\\u{193}'\n    'Ɣ',      // 'Ɣ'; '\\u{194}'\n    'h',      // 'ƕ'; '\\u{195}'\n    'I',      // 'Ɩ'; '\\u{196}'\n    'I',      // 'Ɨ'; '\\u{197}'\n    'Ƙ',      // 'Ƙ'; '\\u{198}'\n    'k',      // 'ƙ'; '\\u{199}'\n    'l',      // 'ƚ'; '\\u{19a}'\n    'ƛ',      // 'ƛ'; '\\u{19b}'\n    'M',      // 'Ɯ'; '\\u{19c}'\n    'N',      // 'Ɲ'; '\\u{19d}'\n    'n',      // 'ƞ'; '\\u{19e}'\n    'O',      // 'Ɵ'; '\\u{19f}'\n    'O',      // 'Ơ'; '\\u{1a0}'\n    'o',      // 'ơ'; '\\u{1a1}'\n    'Ƣ',      // 'Ƣ'; '\\u{1a2}'\n    'ƣ',      // 'ƣ'; '\\u{1a3}'\n    'P',      // 'Ƥ'; '\\u{1a4}'\n    'p',      // 'ƥ'; '\\u{1a5}'\n    'R',      // 'Ʀ'; '\\u{1a6}'\n    'S',      // 'Ƨ'; '\\u{1a7}'\n    's',      // 'ƨ'; '\\u{1a8}'\n    'Ʃ',      // 'Ʃ'; '\\u{1a9}'\n    'l',      // 'ƪ'; '\\u{1aa}'\n    't',      // 'ƫ'; '\\u{1ab}'\n    'T',      // 'Ƭ'; '\\u{1ac}'\n    't',      // 'ƭ'; '\\u{1ad}'\n    'T',      // 'Ʈ'; '\\u{1ae}'\n    'U',      // 'Ư'; '\\u{1af}'\n    'u',      // 'ư'; '\\u{1b0}'\n    'Ʊ',      // 'Ʊ'; '\\u{1b1}'\n    'V',      // 'Ʋ'; '\\u{1b2}'\n    'Y',      // 'Ƴ'; '\\u{1b3}'\n    'y',      // 'ƴ'; '\\u{1b4}'\n    'Z',      // 'Ƶ'; '\\u{1b5}'\n    'z',      // 'ƶ'; '\\u{1b6}'\n    'Ʒ',      // 'Ʒ'; '\\u{1b7}'\n    'Ƹ',      // 'Ƹ'; '\\u{1b8}'\n    'ƹ',      // 'ƹ'; '\\u{1b9}'\n    'ƺ',      // 'ƺ'; '\\u{1ba}'\n    'ƻ',      // 'ƻ'; '\\u{1bb}'\n    'Ƽ',      // 'Ƽ'; '\\u{1bc}'\n    'ƽ',      // 'ƽ'; '\\u{1bd}'\n    'ƾ',      // 'ƾ'; '\\u{1be}'\n    'ƿ',      // 'ƿ'; '\\u{1bf}'\n    'ǀ',      // 'ǀ'; '\\u{1c0}'\n    'ǁ',      // 'ǁ'; '\\u{1c1}'\n    'ǂ',      // 'ǂ'; '\\u{1c2}'\n    '!',      // 'ǃ'; '\\u{1c3}'\n    'Ǆ',      // 'Ǆ'; '\\u{1c4}'\n    'ǅ',      // 'ǅ'; '\\u{1c5}'\n    'ǆ',      // 'ǆ'; '\\u{1c6}'\n    'Ǉ',      // 'Ǉ'; '\\u{1c7}'\n    'ǈ',      // 'ǈ'; '\\u{1c8}'\n    'ǉ',      // 'ǉ'; '\\u{1c9}'\n    'Ǌ',      // 'Ǌ'; '\\u{1ca}'\n    'ǋ',      // 'ǋ'; '\\u{1cb}'\n    'ǌ',      // 'ǌ'; '\\u{1cc}'\n    'A',      // 'Ǎ'; '\\u{1cd}'\n    'a',      // 'ǎ'; '\\u{1ce}'\n    'I',      // 'Ǐ'; '\\u{1cf}'\n    'i',      // 'ǐ'; '\\u{1d0}'\n    'O',      // 'Ǒ'; '\\u{1d1}'\n    'o',      // 'ǒ'; '\\u{1d2}'\n    'U',      // 'Ǔ'; '\\u{1d3}'\n    'u',      // 'ǔ'; '\\u{1d4}'\n    'U',      // 'Ǖ'; '\\u{1d5}'\n    'u',      // 'ǖ'; '\\u{1d6}'\n    'U',      // 'Ǘ'; '\\u{1d7}'\n    'u',      // 'ǘ'; '\\u{1d8}'\n    'U',      // 'Ǚ'; '\\u{1d9}'\n    'u',      // 'ǚ'; '\\u{1da}'\n    'U',      // 'Ǜ'; '\\u{1db}'\n    'u',      // 'ǜ'; '\\u{1dc}'\n    'e',      // 'ǝ'; '\\u{1dd}'\n    'A',      // 'Ǟ'; '\\u{1de}'\n    'a',      // 'ǟ'; '\\u{1df}'\n    'A',      // 'Ǡ'; '\\u{1e0}'\n    'a',      // 'ǡ'; '\\u{1e1}'\n    'Æ',      // 'Ǣ'; '\\u{1e2}'\n    'æ',      // 'ǣ'; '\\u{1e3}'\n    'G',      // 'Ǥ'; '\\u{1e4}'\n    'g',      // 'ǥ'; '\\u{1e5}'\n    'G',      // 'Ǧ'; '\\u{1e6}'\n    'g',      // 'ǧ'; '\\u{1e7}'\n    'K',      // 'Ǩ'; '\\u{1e8}'\n    'k',      // 'ǩ'; '\\u{1e9}'\n    'O',      // 'Ǫ'; '\\u{1ea}'\n    'o',      // 'ǫ'; '\\u{1eb}'\n    'O',      // 'Ǭ'; '\\u{1ec}'\n    'o',      // 'ǭ'; '\\u{1ed}'\n    'Ǯ',      // 'Ǯ'; '\\u{1ee}'\n    'ǯ',      // 'ǯ'; '\\u{1ef}'\n    'j',      // 'ǰ'; '\\u{1f0}'\n    'Ǳ',      // 'Ǳ'; '\\u{1f1}'\n    'ǲ',      // 'ǲ'; '\\u{1f2}'\n    'ǳ',      // 'ǳ'; '\\u{1f3}'\n    'G',      // 'Ǵ'; '\\u{1f4}'\n    'g',      // 'ǵ'; '\\u{1f5}'\n    'Ƕ',      // 'Ƕ'; '\\u{1f6}'\n    'Ƿ',      // 'Ƿ'; '\\u{1f7}'\n    'N',      // 'Ǹ'; '\\u{1f8}'\n    'n',      // 'ǹ'; '\\u{1f9}'\n    'A',      // 'Ǻ'; '\\u{1fa}'\n    'a',      // 'ǻ'; '\\u{1fb}'\n    'Æ',      // 'Ǽ'; '\\u{1fc}'\n    'æ',      // 'ǽ'; '\\u{1fd}'\n    'O',      // 'Ǿ'; '\\u{1fe}'\n    'o',      // 'ǿ'; '\\u{1ff}'\n    'A',      // 'Ȁ'; '\\u{200}'\n    'a',      // 'ȁ'; '\\u{201}'\n    'A',      // 'Ȃ'; '\\u{202}'\n    'a',      // 'ȃ'; '\\u{203}'\n    'E',      // 'Ȅ'; '\\u{204}'\n    'e',      // 'ȅ'; '\\u{205}'\n    'E',      // 'Ȇ'; '\\u{206}'\n    'e',      // 'ȇ'; '\\u{207}'\n    'I',      // 'Ȉ'; '\\u{208}'\n    'i',      // 'ȉ'; '\\u{209}'\n    'I',      // 'Ȋ'; '\\u{20a}'\n    'i',      // 'ȋ'; '\\u{20b}'\n    'O',      // 'Ȍ'; '\\u{20c}'\n    'o',      // 'ȍ'; '\\u{20d}'\n    'O',      // 'Ȏ'; '\\u{20e}'\n    'o',      // 'ȏ'; '\\u{20f}'\n    'R',      // 'Ȑ'; '\\u{210}'\n    'r',      // 'ȑ'; '\\u{211}'\n    'R',      // 'Ȓ'; '\\u{212}'\n    'r',      // 'ȓ'; '\\u{213}'\n    'U',      // 'Ȕ'; '\\u{214}'\n    'u',      // 'ȕ'; '\\u{215}'\n    'U',      // 'Ȗ'; '\\u{216}'\n    'u',      // 'ȗ'; '\\u{217}'\n    'S',      // 'Ș'; '\\u{218}'\n    's',      // 'ș'; '\\u{219}'\n    'T',      // 'Ț'; '\\u{21a}'\n    't',      // 'ț'; '\\u{21b}'\n    'Ȝ',      // 'Ȝ'; '\\u{21c}'\n    'ȝ',      // 'ȝ'; '\\u{21d}'\n    'H',      // 'Ȟ'; '\\u{21e}'\n    'h',      // 'ȟ'; '\\u{21f}'\n    'N',      // 'Ƞ'; '\\u{220}'\n    'd',      // 'ȡ'; '\\u{221}'\n    'Ȣ',      // 'Ȣ'; '\\u{222}'\n    'ȣ',      // 'ȣ'; '\\u{223}'\n    'Z',      // 'Ȥ'; '\\u{224}'\n    'z',      // 'ȥ'; '\\u{225}'\n    'A',      // 'Ȧ'; '\\u{226}'\n    'a',      // 'ȧ'; '\\u{227}'\n    'E',      // 'Ȩ'; '\\u{228}'\n    'e',      // 'ȩ'; '\\u{229}'\n    'O',      // 'Ȫ'; '\\u{22a}'\n    'o',      // 'ȫ'; '\\u{22b}'\n    'O',      // 'Ȭ'; '\\u{22c}'\n    'o',      // 'ȭ'; '\\u{22d}'\n    'O',      // 'Ȯ'; '\\u{22e}'\n    'o',      // 'ȯ'; '\\u{22f}'\n    'O',      // 'Ȱ'; '\\u{230}'\n    'o',      // 'ȱ'; '\\u{231}'\n    'Y',      // 'Ȳ'; '\\u{232}'\n    'y',      // 'ȳ'; '\\u{233}'\n    'l',      // 'ȴ'; '\\u{234}'\n    'n',      // 'ȵ'; '\\u{235}'\n    't',      // 'ȶ'; '\\u{236}'\n    'j',      // 'ȷ'; '\\u{237}'\n    'ȸ',      // 'ȸ'; '\\u{238}'\n    'ȹ',      // 'ȹ'; '\\u{239}'\n    'A',      // 'Ⱥ'; '\\u{23a}'\n    'C',      // 'Ȼ'; '\\u{23b}'\n    'c',      // 'ȼ'; '\\u{23c}'\n    'L',      // 'Ƚ'; '\\u{23d}'\n    'T',      // 'Ⱦ'; '\\u{23e}'\n    's',      // 'ȿ'; '\\u{23f}'\n    'z',      // 'ɀ'; '\\u{240}'\n    'Ɂ',      // 'Ɂ'; '\\u{241}'\n    'ɂ',      // 'ɂ'; '\\u{242}'\n    'B',      // 'Ƀ'; '\\u{243}'\n    'U',      // 'Ʉ'; '\\u{244}'\n    'V',      // 'Ʌ'; '\\u{245}'\n    'E',      // 'Ɇ'; '\\u{246}'\n    'e',      // 'ɇ'; '\\u{247}'\n    'J',      // 'Ɉ'; '\\u{248}'\n    'j',      // 'ɉ'; '\\u{249}'\n    'Q',      // 'Ɋ'; '\\u{24a}'\n    'q',      // 'ɋ'; '\\u{24b}'\n    'R',      // 'Ɍ'; '\\u{24c}'\n    'r',      // 'ɍ'; '\\u{24d}'\n    'Y',      // 'Ɏ'; '\\u{24e}'\n    'y',      // 'ɏ'; '\\u{24f}'\n    'a',      // 'ɐ'; '\\u{250}'\n    'a',      // 'ɑ'; '\\u{251}'\n    'a',      // 'ɒ'; '\\u{252}'\n    'b',      // 'ɓ'; '\\u{253}'\n    'c',      // 'ɔ'; '\\u{254}'\n    'c',      // 'ɕ'; '\\u{255}'\n    'd',      // 'ɖ'; '\\u{256}'\n    'd',      // 'ɗ'; '\\u{257}'\n    'e',      // 'ɘ'; '\\u{258}'\n    'e',      // 'ə'; '\\u{259}'\n    'e',      // 'ɚ'; '\\u{25a}'\n    'e',      // 'ɛ'; '\\u{25b}'\n    'e',      // 'ɜ'; '\\u{25c}'\n    'e',      // 'ɝ'; '\\u{25d}'\n    'e',      // 'ɞ'; '\\u{25e}'\n    'j',      // 'ɟ'; '\\u{25f}'\n    'g',      // 'ɠ'; '\\u{260}'\n    'g',      // 'ɡ'; '\\u{261}'\n    'G',      // 'ɢ'; '\\u{262}'\n    'g',      // 'ɣ'; '\\u{263}'\n    'u',      // 'ɤ'; '\\u{264}'\n    'h',      // 'ɥ'; '\\u{265}'\n    'h',      // 'ɦ'; '\\u{266}'\n    'h',      // 'ɧ'; '\\u{267}'\n    'i',      // 'ɨ'; '\\u{268}'\n    'i',      // 'ɩ'; '\\u{269}'\n    'I',      // 'ɪ'; '\\u{26a}'\n    'l',      // 'ɫ'; '\\u{26b}'\n    'l',      // 'ɬ'; '\\u{26c}'\n    'l',      // 'ɭ'; '\\u{26d}'\n    'ɮ',      // 'ɮ'; '\\u{26e}'\n    'm',      // 'ɯ'; '\\u{26f}'\n    'm',      // 'ɰ'; '\\u{270}'\n    'm',      // 'ɱ'; '\\u{271}'\n    'n',      // 'ɲ'; '\\u{272}'\n    'n',      // 'ɳ'; '\\u{273}'\n    'N',      // 'ɴ'; '\\u{274}'\n    'o',      // 'ɵ'; '\\u{275}'\n    'ɶ',      // 'ɶ'; '\\u{276}'\n    'ɷ',      // 'ɷ'; '\\u{277}'\n    'ɸ',      // 'ɸ'; '\\u{278}'\n    'r',      // 'ɹ'; '\\u{279}'\n    'r',      // 'ɺ'; '\\u{27a}'\n    'r',      // 'ɻ'; '\\u{27b}'\n    'r',      // 'ɼ'; '\\u{27c}'\n    'r',      // 'ɽ'; '\\u{27d}'\n    'r',      // 'ɾ'; '\\u{27e}'\n    'r',      // 'ɿ'; '\\u{27f}'\n    'R',      // 'ʀ'; '\\u{280}'\n    'R',      // 'ʁ'; '\\u{281}'\n    's',      // 'ʂ'; '\\u{282}'\n    'ʃ',      // 'ʃ'; '\\u{283}'\n    'ʄ',      // 'ʄ'; '\\u{284}'\n    'ʅ',      // 'ʅ'; '\\u{285}'\n    'ʆ',      // 'ʆ'; '\\u{286}'\n    't',      // 'ʇ'; '\\u{287}'\n    't',      // 'ʈ'; '\\u{288}'\n    'u',      // 'ʉ'; '\\u{289}'\n    'ʊ',      // 'ʊ'; '\\u{28a}'\n    'v',      // 'ʋ'; '\\u{28b}'\n    'v',      // 'ʌ'; '\\u{28c}'\n    'w',      // 'ʍ'; '\\u{28d}'\n    'y',      // 'ʎ'; '\\u{28e}'\n    'Y',      // 'ʏ'; '\\u{28f}'\n    'z',      // 'ʐ'; '\\u{290}'\n    'z',      // 'ʑ'; '\\u{291}'\n    'ʒ',      // 'ʒ'; '\\u{292}'\n    'ʓ',      // 'ʓ'; '\\u{293}'\n    'ʔ',      // 'ʔ'; '\\u{294}'\n    'ʕ',      // 'ʕ'; '\\u{295}'\n    'ʖ',      // 'ʖ'; '\\u{296}'\n    'c',      // 'ʗ'; '\\u{297}'\n    'ʘ',      // 'ʘ'; '\\u{298}'\n    'B',      // 'ʙ'; '\\u{299}'\n    'e',      // 'ʚ'; '\\u{29a}'\n    'G',      // 'ʛ'; '\\u{29b}'\n    'H',      // 'ʜ'; '\\u{29c}'\n    'j',      // 'ʝ'; '\\u{29d}'\n    'k',      // 'ʞ'; '\\u{29e}'\n    'L',      // 'ʟ'; '\\u{29f}'\n];\n\n/// A char array corresponding to the following Unicode block:\n///\n/// - [Latin Extended Additional](https://en.wikipedia.org/wiki/Latin_Extended_Additional)\n///\n/// This covers the range `'\\u{1e00}'..='\\u{1eff}'`.\nstatic LATIN_EXTENDED_ADDITIONAL: [char; 256] = [\n    'A', // 'Ḁ'; '\\u{1e00}'\n    'a', // 'ḁ'; '\\u{1e01}'\n    'B', // 'Ḃ'; '\\u{1e02}'\n    'b', // 'ḃ'; '\\u{1e03}'\n    'B', // 'Ḅ'; '\\u{1e04}'\n    'b', // 'ḅ'; '\\u{1e05}'\n    'B', // 'Ḇ'; '\\u{1e06}'\n    'b', // 'ḇ'; '\\u{1e07}'\n    'C', // 'Ḉ'; '\\u{1e08}'\n    'c', // 'ḉ'; '\\u{1e09}'\n    'D', // 'Ḋ'; '\\u{1e0a}'\n    'e', // 'ḋ'; '\\u{1e0b}'\n    'D', // 'Ḍ'; '\\u{1e0c}'\n    'd', // 'ḍ'; '\\u{1e0d}'\n    'D', // 'Ḏ'; '\\u{1e0e}'\n    'd', // 'ḏ'; '\\u{1e0f}'\n    'D', // 'Ḑ'; '\\u{1e10}'\n    'd', // 'ḑ'; '\\u{1e11}'\n    'D', // 'Ḓ'; '\\u{1e12}'\n    'd', // 'ḓ'; '\\u{1e13}'\n    'E', // 'Ḕ'; '\\u{1e14}'\n    'e', // 'ḕ'; '\\u{1e15}'\n    'E', // 'Ḗ'; '\\u{1e16}'\n    'e', // 'ḗ'; '\\u{1e17}'\n    'E', // 'Ḙ'; '\\u{1e18}'\n    'e', // 'ḙ'; '\\u{1e19}'\n    'E', // 'Ḛ'; '\\u{1e1a}'\n    'e', // 'ḛ'; '\\u{1e1b}'\n    'E', // 'Ḝ'; '\\u{1e1c}'\n    'e', // 'ḝ'; '\\u{1e1d}'\n    'F', // 'Ḟ'; '\\u{1e1e}'\n    'f', // 'ḟ'; '\\u{1e1f}'\n    'G', // 'Ḡ'; '\\u{1e20}'\n    'g', // 'ḡ'; '\\u{1e21}'\n    'H', // 'Ḣ'; '\\u{1e22}'\n    'g', // 'ḣ'; '\\u{1e23}'\n    'H', // 'Ḥ'; '\\u{1e24}'\n    'g', // 'ḥ'; '\\u{1e25}'\n    'H', // 'Ḧ'; '\\u{1e26}'\n    'g', // 'ḧ'; '\\u{1e27}'\n    'H', // 'Ḩ'; '\\u{1e28}'\n    'g', // 'ḩ'; '\\u{1e29}'\n    'H', // 'Ḫ'; '\\u{1e2a}'\n    'h', // 'ḫ'; '\\u{1e2b}'\n    'I', // 'Ḭ'; '\\u{1e2c}'\n    'i', // 'ḭ'; '\\u{1e2d}'\n    'I', // 'Ḯ'; '\\u{1e2e}'\n    'i', // 'ḯ'; '\\u{1e2f}'\n    'K', // 'Ḱ'; '\\u{1e30}'\n    'k', // 'ḱ'; '\\u{1e31}'\n    'K', // 'Ḳ'; '\\u{1e32}'\n    'k', // 'ḳ'; '\\u{1e33}'\n    'K', // 'Ḵ'; '\\u{1e34}'\n    'k', // 'ḵ'; '\\u{1e35}'\n    'L', // 'Ḷ'; '\\u{1e36}'\n    'l', // 'ḷ'; '\\u{1e37}'\n    'L', // 'Ḹ'; '\\u{1e38}'\n    'l', // 'ḹ'; '\\u{1e39}'\n    'L', // 'Ḻ'; '\\u{1e3a}'\n    'l', // 'ḻ'; '\\u{1e3b}'\n    'L', // 'Ḽ'; '\\u{1e3c}'\n    'l', // 'ḽ'; '\\u{1e3d}'\n    'M', // 'Ḿ'; '\\u{1e3e}'\n    'm', // 'ḿ'; '\\u{1e3f}'\n    'M', // 'Ṁ'; '\\u{1e40}'\n    'm', // 'ṁ'; '\\u{1e41}'\n    'M', // 'Ṃ'; '\\u{1e42}'\n    'm', // 'ṃ'; '\\u{1e43}'\n    'N', // 'Ṅ'; '\\u{1e44}'\n    'n', // 'ṅ'; '\\u{1e45}'\n    'N', // 'Ṇ'; '\\u{1e46}'\n    'n', // 'ṇ'; '\\u{1e47}'\n    'N', // 'Ṉ'; '\\u{1e48}'\n    'n', // 'ṉ'; '\\u{1e49}'\n    'N', // 'Ṋ'; '\\u{1e4a}'\n    'n', // 'ṋ'; '\\u{1e4b}'\n    'O', // 'Ṍ'; '\\u{1e4c}'\n    'o', // 'ṍ'; '\\u{1e4d}'\n    'O', // 'Ṏ'; '\\u{1e4e}'\n    'o', // 'ṏ'; '\\u{1e4f}'\n    'O', // 'Ṑ'; '\\u{1e50}'\n    'o', // 'ṑ'; '\\u{1e51}'\n    'O', // 'Ṓ'; '\\u{1e52}'\n    'o', // 'ṓ'; '\\u{1e53}'\n    'P', // 'Ṕ'; '\\u{1e54}'\n    'p', // 'ṕ'; '\\u{1e55}'\n    'P', // 'Ṗ'; '\\u{1e56}'\n    'p', // 'ṗ'; '\\u{1e57}'\n    'R', // 'Ṙ'; '\\u{1e58}'\n    'r', // 'ṙ'; '\\u{1e59}'\n    'R', // 'Ṛ'; '\\u{1e5a}'\n    'r', // 'ṛ'; '\\u{1e5b}'\n    'R', // 'Ṝ'; '\\u{1e5c}'\n    'r', // 'ṝ'; '\\u{1e5d}'\n    'R', // 'Ṟ'; '\\u{1e5e}'\n    'r', // 'ṟ'; '\\u{1e5f}'\n    'S', // 'Ṡ'; '\\u{1e60}'\n    's', // 'ṡ'; '\\u{1e61}'\n    'S', // 'Ṣ'; '\\u{1e62}'\n    's', // 'ṣ'; '\\u{1e63}'\n    'S', // 'Ṥ'; '\\u{1e64}'\n    's', // 'ṥ'; '\\u{1e65}'\n    'S', // 'Ṧ'; '\\u{1e66}'\n    's', // 'ṧ'; '\\u{1e67}'\n    'S', // 'Ṩ'; '\\u{1e68}'\n    's', // 'ṩ'; '\\u{1e69}'\n    'T', // 'Ṫ'; '\\u{1e6a}'\n    't', // 'ṫ'; '\\u{1e6b}'\n    'T', // 'Ṭ'; '\\u{1e6c}'\n    't', // 'ṭ'; '\\u{1e6d}'\n    'T', // 'Ṯ'; '\\u{1e6e}'\n    't', // 'ṯ'; '\\u{1e6f}'\n    'T', // 'Ṱ'; '\\u{1e70}'\n    't', // 'ṱ'; '\\u{1e71}'\n    'U', // 'Ṳ'; '\\u{1e72}'\n    'u', // 'ṳ'; '\\u{1e73}'\n    'U', // 'Ṵ'; '\\u{1e74}'\n    'u', // 'ṵ'; '\\u{1e75}'\n    'U', // 'Ṷ'; '\\u{1e76}'\n    'u', // 'ṷ'; '\\u{1e77}'\n    'U', // 'Ṹ'; '\\u{1e78}'\n    'u', // 'ṹ'; '\\u{1e79}'\n    'U', // 'Ṻ'; '\\u{1e7a}'\n    'u', // 'ṻ'; '\\u{1e7b}'\n    'V', // 'Ṽ'; '\\u{1e7c}'\n    'v', // 'ṽ'; '\\u{1e7d}'\n    'V', // 'Ṿ'; '\\u{1e7e}'\n    'v', // 'ṿ'; '\\u{1e7f}'\n    'W', // 'Ẁ'; '\\u{1e80}'\n    'w', // 'ẁ'; '\\u{1e81}'\n    'W', // 'Ẃ'; '\\u{1e82}'\n    'w', // 'ẃ'; '\\u{1e83}'\n    'W', // 'Ẅ'; '\\u{1e84}'\n    'w', // 'ẅ'; '\\u{1e85}'\n    'W', // 'Ẇ'; '\\u{1e86}'\n    'w', // 'ẇ'; '\\u{1e87}'\n    'W', // 'Ẉ'; '\\u{1e88}'\n    'j', // 'ẉ'; '\\u{1e89}'\n    'X', // 'Ẋ'; '\\u{1e8a}'\n    'x', // 'ẋ'; '\\u{1e8b}'\n    'X', // 'Ẍ'; '\\u{1e8c}'\n    'x', // 'ẍ'; '\\u{1e8d}'\n    'Y', // 'Ẏ'; '\\u{1e8e}'\n    'y', // 'ẏ'; '\\u{1e8f}'\n    'Z', // 'Ẑ'; '\\u{1e90}'\n    'z', // 'ẑ'; '\\u{1e91}'\n    'Z', // 'Ẓ'; '\\u{1e92}'\n    'z', // 'ẓ'; '\\u{1e93}'\n    'Z', // 'Ẕ'; '\\u{1e94}'\n    'z', // 'ẕ'; '\\u{1e95}'\n    'h', // 'ẖ'; '\\u{1e96}'\n    't', // 'ẗ'; '\\u{1e97}'\n    'w', // 'ẘ'; '\\u{1e98}'\n    'y', // 'ẙ'; '\\u{1e99}'\n    'a', // 'ẚ'; '\\u{1e9a}'\n    'i', // 'ẛ'; '\\u{1e9b}'\n    'f', // 'ẜ'; '\\u{1e9c}'\n    'f', // 'ẝ'; '\\u{1e9d}'\n    'ẞ', // 'ẞ'; '\\u{1e9e}'\n    'ẟ', // 'ẟ'; '\\u{1e9f}'\n    'A', // 'Ạ'; '\\u{1ea0}'\n    'a', // 'ạ'; '\\u{1ea1}'\n    'A', // 'Ả'; '\\u{1ea2}'\n    'a', // 'ả'; '\\u{1ea3}'\n    'A', // 'Ấ'; '\\u{1ea4}'\n    'a', // 'ấ'; '\\u{1ea5}'\n    'A', // 'Ầ'; '\\u{1ea6}'\n    'a', // 'ầ'; '\\u{1ea7}'\n    'A', // 'Ẩ'; '\\u{1ea8}'\n    'a', // 'ẩ'; '\\u{1ea9}'\n    'A', // 'Ẫ'; '\\u{1eaa}'\n    'a', // 'ẫ'; '\\u{1eab}'\n    'A', // 'Ậ'; '\\u{1eac}'\n    'a', // 'ậ'; '\\u{1ead}'\n    'A', // 'Ắ'; '\\u{1eae}'\n    'a', // 'ắ'; '\\u{1eaf}'\n    'A', // 'Ằ'; '\\u{1eb0}'\n    'a', // 'ằ'; '\\u{1eb1}'\n    'A', // 'Ẳ'; '\\u{1eb2}'\n    'a', // 'ẳ'; '\\u{1eb3}'\n    'A', // 'Ẵ'; '\\u{1eb4}'\n    'a', // 'ẵ'; '\\u{1eb5}'\n    'A', // 'Ặ'; '\\u{1eb6}'\n    'a', // 'ặ'; '\\u{1eb7}'\n    'E', // 'Ẹ'; '\\u{1eb8}'\n    'e', // 'ẹ'; '\\u{1eb9}'\n    'E', // 'Ẻ'; '\\u{1eba}'\n    'e', // 'ẻ'; '\\u{1ebb}'\n    'E', // 'Ẽ'; '\\u{1ebc}'\n    'e', // 'ẽ'; '\\u{1ebd}'\n    'E', // 'Ế'; '\\u{1ebe}'\n    'e', // 'ế'; '\\u{1ebf}'\n    'E', // 'Ề'; '\\u{1ec0}'\n    'e', // 'ề'; '\\u{1ec1}'\n    'E', // 'Ể'; '\\u{1ec2}'\n    'e', // 'ể'; '\\u{1ec3}'\n    'E', // 'Ễ'; '\\u{1ec4}'\n    'e', // 'ễ'; '\\u{1ec5}'\n    'E', // 'Ệ'; '\\u{1ec6}'\n    'e', // 'ệ'; '\\u{1ec7}'\n    'I', // 'Ỉ'; '\\u{1ec8}'\n    'i', // 'ỉ'; '\\u{1ec9}'\n    'I', // 'Ị'; '\\u{1eca}'\n    'i', // 'ị'; '\\u{1ecb}'\n    'O', // 'Ọ'; '\\u{1ecc}'\n    'o', // 'ọ'; '\\u{1ecd}'\n    'O', // 'Ỏ'; '\\u{1ece}'\n    'o', // 'ỏ'; '\\u{1ecf}'\n    'O', // 'Ố'; '\\u{1ed0}'\n    'o', // 'ố'; '\\u{1ed1}'\n    'O', // 'Ồ'; '\\u{1ed2}'\n    'o', // 'ồ'; '\\u{1ed3}'\n    'O', // 'Ổ'; '\\u{1ed4}'\n    'o', // 'ổ'; '\\u{1ed5}'\n    'O', // 'Ỗ'; '\\u{1ed6}'\n    'o', // 'ỗ'; '\\u{1ed7}'\n    'O', // 'Ộ'; '\\u{1ed8}'\n    'o', // 'ộ'; '\\u{1ed9}'\n    'O', // 'Ớ'; '\\u{1eda}'\n    'o', // 'ớ'; '\\u{1edb}'\n    'O', // 'Ờ'; '\\u{1edc}'\n    'o', // 'ờ'; '\\u{1edd}'\n    'O', // 'Ở'; '\\u{1ede}'\n    'o', // 'ở'; '\\u{1edf}'\n    'O', // 'Ỡ'; '\\u{1ee0}'\n    'o', // 'ỡ'; '\\u{1ee1}'\n    'O', // 'Ợ'; '\\u{1ee2}'\n    'o', // 'ợ'; '\\u{1ee3}'\n    'U', // 'Ụ'; '\\u{1ee4}'\n    'u', // 'ụ'; '\\u{1ee5}'\n    'U', // 'Ủ'; '\\u{1ee6}'\n    'u', // 'ủ'; '\\u{1ee7}'\n    'U', // 'Ứ'; '\\u{1ee8}'\n    'u', // 'ứ'; '\\u{1ee9}'\n    'U', // 'Ừ'; '\\u{1eea}'\n    'u', // 'ừ'; '\\u{1eeb}'\n    'U', // 'Ử'; '\\u{1eec}'\n    'u', // 'ử'; '\\u{1eed}'\n    'U', // 'Ữ'; '\\u{1eee}'\n    'u', // 'ữ'; '\\u{1eef}'\n    'U', // 'Ự'; '\\u{1ef0}'\n    'u', // 'ự'; '\\u{1ef1}'\n    'Y', // 'Ỳ'; '\\u{1ef2}'\n    'y', // 'ỳ'; '\\u{1ef3}'\n    'Y', // 'Ỵ'; '\\u{1ef4}'\n    'y', // 'ỵ'; '\\u{1ef5}'\n    'Y', // 'Ỷ'; '\\u{1ef6}'\n    'y', // 'ỷ'; '\\u{1ef7}'\n    'Y', // 'Ỹ'; '\\u{1ef8}'\n    'y', // 'ỹ'; '\\u{1ef9}'\n    'Ỻ', // 'Ỻ'; '\\u{1efa}'\n    'ỻ', // 'ỻ'; '\\u{1efb}'\n    'Ỽ', // 'Ỽ'; '\\u{1efc}'\n    'ỽ', // 'ỽ'; '\\u{1efd}'\n    'Ỿ', // 'Ỿ'; '\\u{1efe}'\n    'ỿ', // 'ỿ'; '\\u{1eff}'\n];\n\n/// A char array corresponding to the following Unicode block:\n///\n/// - [Superscripts and Subscripts](https://en.wikipedia.org/wiki/Superscripts_and_Subscripts)\n///\n/// This covers the range `'\\u{2070}'..='\\u{209f}'`.\nstatic SUPERSCRIPTS_AND_SUBSCRIPTS: [char; 48] = [\n    '0', // '⁰'; '\\u{2070}'\n    'i', // 'ⁱ'; '\\u{2071}'\n    '⁲', // '⁲'; '\\u{2072}'\n    '⁳', // '⁳'; '\\u{2073}'\n    '4', // '⁴'; '\\u{2074}'\n    '5', // '⁵'; '\\u{2075}'\n    '6', // '⁶'; '\\u{2076}'\n    '7', // '⁷'; '\\u{2077}'\n    '8', // '⁸'; '\\u{2078}'\n    '0', // '⁹'; '\\u{2079}'\n    '+', // '⁺'; '\\u{207a}'\n    '-', // '⁻'; '\\u{207b}'\n    '=', // '⁼'; '\\u{207c}'\n    '(', // '⁽'; '\\u{207d}'\n    ')', // '⁾'; '\\u{207e}'\n    'n', // 'ⁿ'; '\\u{207f}'\n    '0', // '₀'; '\\u{2080}'\n    '1', // '₁'; '\\u{2081}'\n    '2', // '₂'; '\\u{2082}'\n    '3', // '₃'; '\\u{2083}'\n    '4', // '₄'; '\\u{2084}'\n    '5', // '₅'; '\\u{2085}'\n    '6', // '₆'; '\\u{2086}'\n    '7', // '₇'; '\\u{2087}'\n    '8', // '₈'; '\\u{2088}'\n    '9', // '₉'; '\\u{2089}'\n    '+', // '₊'; '\\u{208a}'\n    '-', // '₋'; '\\u{208b}'\n    '=', // '₌'; '\\u{208c}'\n    '(', // '₍'; '\\u{208d}'\n    ')', // '₎'; '\\u{208e}'\n    '₏', // '₏'; '\\u{208f}'\n    'a', // 'ₐ'; '\\u{2090}'\n    'e', // 'ₑ'; '\\u{2091}'\n    'o', // 'ₒ'; '\\u{2092}'\n    'x', // 'ₓ'; '\\u{2093}'\n    'e', // 'ₔ'; '\\u{2094}'\n    'h', // 'ₕ'; '\\u{2095}'\n    'k', // 'ₖ'; '\\u{2096}'\n    'l', // 'ₗ'; '\\u{2097}'\n    'm', // 'ₘ'; '\\u{2098}'\n    'n', // 'ₙ'; '\\u{2099}'\n    'p', // 'ₚ'; '\\u{209a}'\n    's', // 'ₛ'; '\\u{209b}'\n    't', // 'ₜ'; '\\u{209c}'\n    '₝', // '₝'; '\\u{209d}'\n    '₞', // '₞'; '\\u{209e}'\n    '₟', // '₟'; '\\u{209f}'\n];\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    /// Helper function for test assertions.\n    fn check_conversions(pairs: &[(char, char)]) {\n        for (original, normalized) in pairs {\n            assert_eq!(normalize(*original), *normalized);\n        }\n    }\n\n    /// General conversion checks\n    #[test]\n    fn general() {\n        check_conversions(&[\n            ('ą', 'a'),\n            ('À', 'A'),\n            ('ć', 'c'),\n            ('ę', 'e'),\n            ('ł', 'l'),\n            ('ń', 'n'),\n            ('ó', 'o'),\n            ('ś', 's'),\n            ('ź', 'z'),\n            ('ż', 'z'),\n            ('Ą', 'A'),\n            ('Ć', 'C'),\n            ('Ę', 'E'),\n            ('ł', 'l'),\n            ('Ł', 'L'),\n            ('Ń', 'N'),\n            ('Ó', 'O'),\n            ('Ś', 'S'),\n            ('Ź', 'Z'),\n            ('Ż', 'Z'),\n            ('¡', '!'),\n        ]);\n    }\n\n    /// Some checks for characters which are not visible.\n    #[test]\n    fn invisible_chars() {\n        check_conversions(&[('\\u{a0}', '\\u{a0}'), ('\\u{ad}', '\\u{ad}')]);\n    }\n\n    /// Check boundary cases in case ranges are modified.\n    #[test]\n    fn boundary_cases() {\n        check_conversions(&[\n            ('\\u{9f}', '\\u{9f}'),\n            ('\\u{a0}', '\\u{a0}'),\n            ('¡', '!'),\n            ('ʟ', 'L'),\n            ('\\u{2a0}', '\\u{2a0}'),\n            ('\\u{1dff}', '\\u{1dff}'),\n            ('Ḁ', 'A'),\n            ('ỹ', 'y'),\n            ('\\u{1eff}', '\\u{1eff}'),\n            ('\\u{1f00}', '\\u{1f00}'),\n            ('⁰', '0'),\n            ('\\u{209c}', 't'),\n            ('\\u{209f}', '\\u{209f}'),\n            ('\\u{20a0}', '\\u{20a0}'),\n        ]);\n    }\n\n    /// Check that conversions outside the blocks are unchanged.\n    #[test]\n    fn unchanged_outside_blocks() {\n        check_conversions(&[\n            ('a', 'a'),\n            ('⟁', '⟁'),\n            ('┍', '┍'),\n            ('ω', 'ω'),\n            ('⁕', '⁕'),\n            ('ה', 'ה'),\n        ]);\n    }\n}\n"
  },
  {
    "path": "crates/atuin-nucleo/matcher/src/chars.rs",
    "content": "//! Utilities for working with (unicode) characters/codepoints\n\nuse std::fmt::{self, Debug, Display};\n\n#[cfg(feature = \"unicode-casefold\")]\nuse crate::chars::case_fold::CASE_FOLDING_SIMPLE;\nuse crate::Config;\n\n//autogenerated by generate-ucd\n#[allow(warnings)]\n#[rustfmt::skip]\n#[cfg(feature = \"unicode-casefold\")]\nmod case_fold;\n#[cfg(feature = \"unicode-normalization\")]\nmod normalize;\n\npub(crate) trait Char: Copy + Eq + Ord + fmt::Display {\n    const ASCII: bool;\n    fn char_class(self, config: &Config) -> CharClass;\n    fn char_class_and_normalize(self, config: &Config) -> (Self, CharClass);\n    fn normalize(self, config: &Config) -> Self;\n}\n\n/// repr tansparent wrapper around u8 with better formatting and `PartialEq<char>` implementation\n#[repr(transparent)]\n#[derive(PartialEq, Eq, PartialOrd, Ord, Clone, Copy)]\npub(crate) struct AsciiChar(pub u8);\n\nimpl AsciiChar {\n    pub fn cast(bytes: &[u8]) -> &[AsciiChar] {\n        unsafe { &*(bytes as *const [u8] as *const [AsciiChar]) }\n    }\n}\n\nimpl fmt::Display for AsciiChar {\n    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {\n        Display::fmt(&(self.0 as char), f)\n    }\n}\n\nimpl PartialEq<AsciiChar> for char {\n    fn eq(&self, other: &AsciiChar) -> bool {\n        other.0 as char == *self\n    }\n}\n\nimpl Char for AsciiChar {\n    const ASCII: bool = true;\n    #[inline]\n    fn char_class(self, config: &Config) -> CharClass {\n        let c = self.0;\n        // using manual if conditions instead optimizes better\n        if c >= b'a' && c <= b'z' {\n            CharClass::Lower\n        } else if c >= b'A' && c <= b'Z' {\n            CharClass::Upper\n        } else if c >= b'0' && c <= b'9' {\n            CharClass::Number\n        } else if c.is_ascii_whitespace() {\n            CharClass::Whitespace\n        } else if config.delimiter_chars.contains(&c) {\n            CharClass::Delimiter\n        } else {\n            CharClass::NonWord\n        }\n    }\n\n    #[inline(always)]\n    fn char_class_and_normalize(mut self, config: &Config) -> (Self, CharClass) {\n        let char_class = self.char_class(config);\n        if config.ignore_case && char_class == CharClass::Upper {\n            self.0 += 32\n        }\n        (self, char_class)\n    }\n\n    #[inline(always)]\n    fn normalize(mut self, config: &Config) -> Self {\n        if config.ignore_case && self.0 >= b'A' && self.0 <= b'Z' {\n            self.0 += 32\n        }\n        self\n    }\n}\nfn char_class_non_ascii(c: char) -> CharClass {\n    if c.is_lowercase() {\n        CharClass::Lower\n    } else if is_upper_case(c) {\n        CharClass::Upper\n    } else if c.is_numeric() {\n        CharClass::Number\n    } else if c.is_alphabetic() {\n        CharClass::Letter\n    } else if c.is_whitespace() {\n        CharClass::Whitespace\n    } else {\n        CharClass::NonWord\n    }\n}\nimpl Char for char {\n    const ASCII: bool = false;\n    #[inline(always)]\n    fn char_class(self, config: &Config) -> CharClass {\n        if self.is_ascii() {\n            return AsciiChar(self as u8).char_class(config);\n        }\n        char_class_non_ascii(self)\n    }\n\n    #[inline(always)]\n    fn char_class_and_normalize(mut self, config: &Config) -> (Self, CharClass) {\n        if self.is_ascii() {\n            let (c, class) = AsciiChar(self as u8).char_class_and_normalize(config);\n            return (c.0 as char, class);\n        }\n        let char_class = char_class_non_ascii(self);\n        #[cfg(feature = \"unicode-casefold\")]\n        let mut case_fold = char_class == CharClass::Upper;\n        #[cfg(feature = \"unicode-normalization\")]\n        if config.normalize {\n            self = normalize::normalize(self);\n            case_fold = true\n        }\n        #[cfg(feature = \"unicode-casefold\")]\n        if case_fold && config.ignore_case {\n            self = CASE_FOLDING_SIMPLE\n                .binary_search_by_key(&self, |(upper, _)| *upper)\n                .map_or(self, |idx| CASE_FOLDING_SIMPLE[idx].1)\n        }\n        (self, char_class)\n    }\n\n    #[inline(always)]\n    fn normalize(mut self, config: &Config) -> Self {\n        #[cfg(feature = \"unicode-normalization\")]\n        if config.normalize {\n            self = normalize::normalize(self);\n        }\n        #[cfg(feature = \"unicode-casefold\")]\n        if config.ignore_case {\n            self = to_lower_case(self)\n        }\n        self\n    }\n}\n\n#[cfg(feature = \"unicode-normalization\")]\npub use normalize::normalize;\n#[cfg(feature = \"unicode-segmentation\")]\nuse unicode_segmentation::UnicodeSegmentation;\n\n/// Converts a character to lower case using simple unicode case folding\n#[cfg(feature = \"unicode-casefold\")]\n#[inline(always)]\npub fn to_lower_case(c: char) -> char {\n    CASE_FOLDING_SIMPLE\n        .binary_search_by_key(&c, |(upper, _)| *upper)\n        .map_or(c, |idx| CASE_FOLDING_SIMPLE[idx].1)\n}\n\n/// Checks if a character is upper case according to simple unicode case folding.\n/// if the `unicode-casefold` feature is disable the equivalent std function is used\n#[inline(always)]\npub fn is_upper_case(c: char) -> bool {\n    #[cfg(feature = \"unicode-casefold\")]\n    let val = CASE_FOLDING_SIMPLE\n        .binary_search_by_key(&c, |(upper, _)| *upper)\n        .is_ok();\n    #[cfg(not(feature = \"unicode-casefold\"))]\n    let val = c.is_uppercase();\n    val\n}\n\n#[derive(Debug, Eq, PartialEq, PartialOrd, Ord, Copy, Clone, Hash)]\npub(crate) enum CharClass {\n    Whitespace,\n    NonWord,\n    Delimiter,\n    Lower,\n    Upper,\n    Letter,\n    Number,\n}\n\n/// Nucleo cannot match graphemes as single units. To work around\n/// that we only use the first codepoint of each grapheme. This\n/// iterator returns the first character of each unicode grapheme\n/// in a string and is used for constructing `Utf32Str(ing)`.\npub fn graphemes(text: &str) -> impl Iterator<Item = char> + '_ {\n    #[cfg(feature = \"unicode-segmentation\")]\n    let res = text.graphemes(true).map(|grapheme| {\n        // we need to special-case this check since `\\r\\n` is a single grapheme and is\n        // therefore the exception to the rule that normalization of a grapheme should\n        // map to the first character.\n        if grapheme == \"\\r\\n\" {\n            '\\n'\n        } else {\n            grapheme\n                .chars()\n                .next()\n                .expect(\"graphemes must be non-empty\")\n        }\n    });\n    #[cfg(not(feature = \"unicode-segmentation\"))]\n    let res = text.chars();\n    res\n}\n"
  },
  {
    "path": "crates/atuin-nucleo/matcher/src/config.rs",
    "content": "use crate::chars::CharClass;\nuse crate::score::BONUS_BOUNDARY;\n\n/// Configuration data that controls how a matcher behaves\n#[non_exhaustive]\n#[derive(PartialEq, Eq, Debug, Clone)]\npub struct Config {\n    /// Characters that act as delimiters and provide bonus\n    /// for matching the following char\n    pub(crate) delimiter_chars: &'static [u8],\n    /// Extra bonus for word boundary after whitespace character or beginning of the string\n    pub(crate) bonus_boundary_white: u16,\n    /// Extra bonus for word boundary after slash, colon, semi-colon, and comma\n    pub(crate) bonus_boundary_delimiter: u16,\n    pub(crate) initial_char_class: CharClass,\n\n    /// Whether to normalize latin script characters to ASCII (enabled by default)\n    pub normalize: bool,\n    /// whether to ignore casing\n    pub ignore_case: bool,\n    /// Whether to provide a bonus to matches by their distance from the start\n    /// of the haystack. The bonus is fairly small compared to the normal gap\n    /// penalty to avoid messing with the normal score heuristic. This setting\n    /// is not turned on by default and only recommended for autocompletion\n    /// usecases where the expectation is that the user is typing the entire\n    /// match. For a full fzf-like fuzzy matcher/picker word segmentation and\n    /// explicit prefix literals should be used instead.\n    pub prefer_prefix: bool,\n}\n\nimpl Config {\n    /// The default config for nucleo, implemented as a constant since\n    /// Default::default can not be called in a const context\n    pub const DEFAULT: Self = {\n        Config {\n            delimiter_chars: b\"/,:;|\",\n            bonus_boundary_white: BONUS_BOUNDARY + 2,\n            bonus_boundary_delimiter: BONUS_BOUNDARY + 1,\n            initial_char_class: CharClass::Whitespace,\n            normalize: true,\n            ignore_case: true,\n            prefer_prefix: false,\n        }\n    };\n}\n\nimpl Config {\n    /// Configures the matcher with bonuses appropriate for matching file paths.\n    pub fn set_match_paths(&mut self) {\n        if cfg!(windows) {\n            self.delimiter_chars = b\"/:\\\\\";\n        } else {\n            self.delimiter_chars = b\"/:\";\n        }\n        self.bonus_boundary_white = BONUS_BOUNDARY;\n        self.initial_char_class = CharClass::Delimiter;\n    }\n\n    /// Configures the matcher with bonuses appropriate for matching file paths.\n    pub const fn match_paths(mut self) -> Self {\n        if cfg!(windows) {\n            self.delimiter_chars = b\"/\\\\\";\n        } else {\n            self.delimiter_chars = b\"/\";\n        }\n        self.bonus_boundary_white = BONUS_BOUNDARY;\n        self.initial_char_class = CharClass::Delimiter;\n        self\n    }\n}\n"
  },
  {
    "path": "crates/atuin-nucleo/matcher/src/debug.rs",
    "content": "use crate::matrix::{MatrixCell, ScoreCell};\nuse std::fmt::{Debug, Formatter, Result};\n\nimpl Debug for ScoreCell {\n    fn fmt(&self, f: &mut Formatter<'_>) -> Result {\n        write!(f, \"({}, {})\", self.score, self.matched)\n    }\n}\n\nimpl Debug for MatrixCell {\n    fn fmt(&self, f: &mut Formatter<'_>) -> Result {\n        write!(f, \"({}, {})\", (self.0 & 1) != 0, (self.0 & 2) != 0)\n    }\n}\n"
  },
  {
    "path": "crates/atuin-nucleo/matcher/src/exact.rs",
    "content": "use memchr::memmem;\nuse memchr::{Memchr, Memchr2};\n\nuse crate::chars::{AsciiChar, Char};\nuse crate::score::{BONUS_FIRST_CHAR_MULTIPLIER, SCORE_MATCH};\nuse crate::Matcher;\n\nimpl Matcher {\n    pub(crate) fn substring_match_1_ascii<const INDICES: bool>(\n        &mut self,\n        haystack: &[u8],\n        c: u8,\n        indices: &mut Vec<u32>,\n    ) -> Option<u16> {\n        let mut max_score = 0;\n        let mut max_pos = 0;\n        if self.config.ignore_case && c >= b'a' && c <= b'z' {\n            for i in Memchr2::new(c, c - 32, haystack) {\n                let prev_char_class = i\n                    .checked_sub(1)\n                    .map(|i| AsciiChar(haystack[i]).char_class(&self.config))\n                    .unwrap_or(self.config.initial_char_class);\n                let char_class = AsciiChar(haystack[i]).char_class(&self.config);\n                let bonus = self.config.bonus_for(prev_char_class, char_class);\n                let score = bonus * BONUS_FIRST_CHAR_MULTIPLIER + SCORE_MATCH;\n                if score > max_score {\n                    max_pos = i as u32;\n                    max_score = score;\n                    // can't get better than this\n                    if bonus >= self.config.bonus_boundary_white {\n                        break;\n                    }\n                }\n            }\n        } else {\n            let char_class = AsciiChar(c).char_class(&self.config);\n            for i in Memchr::new(c, haystack) {\n                let prev_char_class = i\n                    .checked_sub(1)\n                    .map(|i| AsciiChar(haystack[i]).char_class(&self.config))\n                    .unwrap_or(self.config.initial_char_class);\n                let bonus = self.config.bonus_for(prev_char_class, char_class);\n                let score = bonus * BONUS_FIRST_CHAR_MULTIPLIER + SCORE_MATCH;\n                if score > max_score {\n                    max_pos = i as u32;\n                    max_score = score;\n                    // can't get better than this\n                    if bonus >= self.config.bonus_boundary_white {\n                        break;\n                    }\n                }\n            }\n        }\n        if max_score == 0 {\n            return None;\n        }\n\n        if INDICES {\n            indices.push(max_pos);\n        }\n        Some(max_score)\n    }\n\n    pub(crate) fn substring_match_ascii_with_prefilter(\n        &mut self,\n        haystack: &[u8],\n        needle: &[u8],\n        prefilter_len: usize,\n        prefilter: impl Iterator<Item = usize>,\n    ) -> (u16, usize) {\n        let needle_without_prefilter = &needle[prefilter_len..];\n        let mut max_score = 0;\n        let mut max_pos = 0;\n        for i in prefilter {\n            let prev_char_class = i\n                .checked_sub(1)\n                .map(|i| AsciiChar(haystack[i]).char_class(&self.config))\n                .unwrap_or(self.config.initial_char_class);\n            let char_class = AsciiChar(haystack[i]).char_class(&self.config);\n            let bonus = self.config.bonus_for(prev_char_class, char_class);\n            let score = bonus * BONUS_FIRST_CHAR_MULTIPLIER + SCORE_MATCH;\n            if score > max_score\n                && haystack[i + prefilter_len..(i + needle.len()).min(haystack.len())]\n                    .iter()\n                    .map(|&c| AsciiChar(c).normalize(&self.config).0)\n                    .eq(needle_without_prefilter.iter().copied())\n            {\n                max_pos = i;\n                max_score = score;\n                // can't get better than this\n                if bonus >= self.config.bonus_boundary_white {\n                    break;\n                }\n            }\n        }\n        (max_score, max_pos)\n    }\n\n    pub(crate) fn substring_match_ascii<const INDICES: bool>(\n        &mut self,\n        haystack: &[u8],\n        needle: &[u8],\n        indices: &mut Vec<u32>,\n    ) -> Option<u16> {\n        let mut max_score = 0;\n        let mut max_pos = 0;\n        if self.config.ignore_case {\n            match needle.iter().position(|&c| c >= b'a' && c <= b'z') {\n                // start with char do case insensitive search\n                Some(0) => {\n                    (max_score, max_pos) = self.substring_match_ascii_with_prefilter(\n                        haystack,\n                        needle,\n                        1,\n                        Memchr2::new(\n                            needle[0],\n                            needle[0] - 32,\n                            &haystack[..haystack.len() - needle.len() + 1],\n                        ),\n                    );\n                    if max_score == 0 {\n                        return None;\n                    }\n                }\n                Some(1) => {\n                    (max_score, max_pos) = self.substring_match_ascii_with_prefilter(\n                        haystack,\n                        needle,\n                        1,\n                        Memchr::new(needle[0], &haystack[..haystack.len() - needle.len() + 1]),\n                    );\n                    if max_score == 0 {\n                        return None;\n                    }\n                }\n                Some(len) => {\n                    (max_score, max_pos) = self.substring_match_ascii_with_prefilter(\n                        haystack,\n                        needle,\n                        1,\n                        memmem::find_iter(&haystack[..haystack.len() - needle.len() + len], needle),\n                    );\n                    if max_score == 0 {\n                        return None;\n                    }\n                }\n                // in case we don't have any letter in the needle\n                // we can treat the search as case sensitive and use memmem directly which is way faster\n                None => (),\n            }\n        }\n\n        if max_score == 0 {\n            let char_class = AsciiChar(needle[0]).char_class(&self.config);\n            for i in memmem::find_iter(haystack, needle) {\n                let prev_char_class = i\n                    .checked_sub(1)\n                    .map(|i| AsciiChar(haystack[i]).char_class(&self.config))\n                    .unwrap_or(self.config.initial_char_class);\n                let bonus = self.config.bonus_for(prev_char_class, char_class);\n                let score = bonus * BONUS_FIRST_CHAR_MULTIPLIER + SCORE_MATCH;\n                if score > max_score {\n                    max_pos = i;\n                    max_score = score;\n                    // can't get better than this\n                    if bonus >= self.config.bonus_boundary_white {\n                        break;\n                    }\n                }\n            }\n            if max_score == 0 {\n                return None;\n            }\n        }\n        let score = self.calculate_score::<INDICES, _, _>(\n            AsciiChar::cast(haystack),\n            AsciiChar::cast(needle),\n            max_pos,\n            max_pos + needle.len(),\n            indices,\n        );\n        Some(score)\n    }\n\n    pub(crate) fn substring_match_1_non_ascii<const INDICES: bool>(\n        &mut self,\n        haystack: &[char],\n        needle: char,\n        start: usize,\n        indices: &mut Vec<u32>,\n    ) -> u16 {\n        let mut max_score = 0;\n        let mut max_pos = 0;\n        let mut prev_class = start\n            .checked_sub(1)\n            .map(|i| haystack[i].char_class(&self.config))\n            .unwrap_or(self.config.initial_char_class);\n        for (i, &c) in haystack[start..].iter().enumerate() {\n            let (c, char_class) = c.char_class_and_normalize(&self.config);\n            if c != needle {\n                continue;\n            }\n            let bonus = self.config.bonus_for(prev_class, char_class);\n            prev_class = char_class;\n            let score = bonus * BONUS_FIRST_CHAR_MULTIPLIER + SCORE_MATCH;\n            if score > max_score {\n                max_pos = i as u32;\n                max_score = score;\n                // can't get better than this\n                if bonus >= self.config.bonus_boundary_white {\n                    break;\n                }\n            }\n        }\n\n        if INDICES {\n            indices.push(max_pos + start as u32);\n        }\n        max_score\n    }\n\n    pub(crate) fn substring_match_non_ascii<const INDICES: bool, N>(\n        &mut self,\n        haystack: &[char],\n        needle: &[N],\n        start: usize,\n        indices: &mut Vec<u32>,\n    ) -> Option<u16>\n    where\n        N: Char,\n        char: PartialEq<N>,\n    {\n        let mut max_score = 0;\n        let mut max_pos = 0;\n        let mut prev_class = start\n            .checked_sub(1)\n            .map(|i| haystack[i].char_class(&self.config))\n            .unwrap_or(self.config.initial_char_class);\n        let end = haystack.len() - needle.len();\n        for (i, &c) in haystack[start..end].iter().enumerate() {\n            let (c, char_class) = c.char_class_and_normalize(&self.config);\n            if c != needle[0] {\n                continue;\n            }\n            let bonus = self.config.bonus_for(prev_class, char_class);\n            prev_class = char_class;\n            let score = bonus * BONUS_FIRST_CHAR_MULTIPLIER + SCORE_MATCH;\n            if score > max_score\n                && haystack[start + i + 1..start + i + needle.len()]\n                    .iter()\n                    .map(|c| c.normalize(&self.config))\n                    .eq(needle[1..].iter().copied())\n            {\n                max_pos = i;\n                max_score = score;\n                // can't get better than this\n                if bonus >= self.config.bonus_boundary_white {\n                    break;\n                }\n            }\n        }\n        if max_score == 0 {\n            return None;\n        }\n\n        let score = self.calculate_score::<INDICES, _, _>(\n            haystack,\n            needle,\n            start + max_pos,\n            start + max_pos + needle.len(),\n            indices,\n        );\n        Some(score)\n    }\n}\n"
  },
  {
    "path": "crates/atuin-nucleo/matcher/src/fuzzy_greedy.rs",
    "content": "use crate::chars::Char;\nuse crate::Matcher;\n\nimpl Matcher {\n    /// greedy fallback algorithm, much faster (linear time) but reported scores/indices\n    /// might not be the best match\n    pub(crate) fn fuzzy_match_greedy_<const INDICES: bool, H: Char + PartialEq<N>, N: Char>(\n        &mut self,\n        haystack: &[H],\n        needle: &[N],\n        mut start: usize,\n        mut end: usize,\n        indices: &mut Vec<u32>,\n    ) -> Option<u16> {\n        let first_char_end = if H::ASCII && N::ASCII { start + 1 } else { end };\n        'nonascii: {\n            if !H::ASCII || !N::ASCII {\n                let mut needle_iter = needle[1..].iter().copied();\n                if let Some(mut needle_char) = needle_iter.next() {\n                    for (i, &c) in haystack[first_char_end..].iter().enumerate() {\n                        if c.normalize(&self.config) == needle_char {\n                            let Some(next_needle_char) = needle_iter.next() else {\n                                // we found a match so we are now in the same state\n                                // as the prefilter would produce\n                                end = first_char_end + i + 1;\n                                break 'nonascii;\n                            };\n                            needle_char = next_needle_char;\n                        }\n                    }\n                    // some needle chars were not matched bail out\n                    return None;\n                }\n            }\n        } // minimize the greedly match by greedy matching in reverse\n\n        let mut needle_iter = needle.iter().rev().copied();\n        let mut needle_char = needle_iter.next().unwrap();\n        for (i, &c) in haystack[start..end].iter().enumerate().rev() {\n            let c = c.normalize(&self.config);\n            if c == needle_char {\n                let Some(next_needle_char) = needle_iter.next() else {\n                    start += i;\n                    break;\n                };\n                needle_char = next_needle_char;\n            }\n        }\n        Some(self.calculate_score::<INDICES, H, N>(haystack, needle, start, end, indices))\n    }\n}\n"
  },
  {
    "path": "crates/atuin-nucleo/matcher/src/fuzzy_optimal.rs",
    "content": "use std::cmp::max;\n\nuse crate::chars::{Char, CharClass};\nuse crate::matrix::{MatcherDataView, MatrixCell, ScoreCell};\nuse crate::score::{\n    BONUS_BOUNDARY, BONUS_CONSECUTIVE, BONUS_FIRST_CHAR_MULTIPLIER, MAX_PREFIX_BONUS,\n    PENALTY_GAP_EXTENSION, PENALTY_GAP_START, PREFIX_BONUS_SCALE, SCORE_MATCH,\n};\nuse crate::{Config, Matcher};\n\nimpl Matcher {\n    pub(crate) fn fuzzy_match_optimal<const INDICES: bool, H: Char + PartialEq<N>, N: Char>(\n        &mut self,\n        haystack: &[H],\n        needle: &[N],\n        start: usize,\n        greedy_end: usize,\n        end: usize,\n        indices: &mut Vec<u32>,\n    ) -> Option<u16> {\n        // construct a matrix (and copy the haystack), the matrix and haystack size are bounded\n        // to avoid the slow O(mn) time complexity for large inputs. Furthermore, it allows\n        // us to treat needle indices as u16\n        let Some(mut matrix) = self.slab.alloc(&haystack[start..end], needle.len()) else {\n            return self.fuzzy_match_greedy_::<INDICES, H, N>(\n                haystack, needle, start, greedy_end, indices,\n            );\n        };\n\n        let prev_class = start\n            .checked_sub(1)\n            .map(|i| haystack[i].char_class(&self.config))\n            .unwrap_or(self.config.initial_char_class);\n        let matched = matrix.setup::<INDICES, _>(needle, prev_class, &self.config, start as u32);\n        // this only happened with unicode haystacks, for ASCII the prefilter handles all rejects\n        if !matched {\n            assert!(\n                !N::ASCII || !H::ASCII,\n                \"Non-match should have been caught by prefilter. Maybe `needle` is not normalized?\"\n            );\n            return None;\n        }\n\n        // populate the matrix and find the best score\n        let matrix_len = matrix.populate_matrix::<INDICES, _>(needle);\n        let last_row_off = matrix.row_offs[needle.len() - 1];\n        let relative_last_row_off = last_row_off as usize + 1 - needle.len();\n        let (match_end, match_score_cell) = matrix.current_row[relative_last_row_off..]\n            .iter()\n            .enumerate()\n            .max_by_key(|(_, cell)| cell.score)\n            .expect(\"there must be at least one match\");\n        if INDICES {\n            matrix.reconstruct_optimal_path(match_end as u16, indices, matrix_len, start as u32);\n        }\n        Some(match_score_cell.score)\n    }\n}\n\nconst UNMATCHED: ScoreCell = ScoreCell {\n    score: 0,\n    // if matched is true then the consecutive bonus\n    // is always at least BONUS_CONSECUTIVE so\n    // this constant can never occur naturally\n    consecutive_bonus: 0,\n    matched: true,\n};\n\nfn next_m_cell(p_score: u16, bonus: u16, m_cell: ScoreCell) -> ScoreCell {\n    if m_cell == UNMATCHED {\n        return ScoreCell {\n            score: p_score + bonus + SCORE_MATCH,\n            matched: false,\n            consecutive_bonus: bonus as u8,\n        };\n    }\n\n    let mut consecutive_bonus = max(m_cell.consecutive_bonus as u16, BONUS_CONSECUTIVE);\n    if bonus >= BONUS_BOUNDARY && bonus > consecutive_bonus {\n        consecutive_bonus = bonus\n    }\n\n    let score_match = m_cell.score + max(consecutive_bonus, bonus);\n    let score_skip = p_score + bonus;\n    if score_match > score_skip {\n        ScoreCell {\n            score: score_match + SCORE_MATCH,\n            matched: true,\n            consecutive_bonus: consecutive_bonus as u8,\n        }\n    } else {\n        ScoreCell {\n            score: score_skip + SCORE_MATCH,\n            matched: false,\n            consecutive_bonus: bonus as u8,\n        }\n    }\n}\n\nfn p_score(prev_p_score: u16, prev_m_score: u16) -> (u16, bool) {\n    let score_match = prev_m_score.saturating_sub(PENALTY_GAP_START);\n    let score_skip = prev_p_score.saturating_sub(PENALTY_GAP_EXTENSION);\n    if score_match > score_skip {\n        (score_match, true)\n    } else {\n        (score_skip, false)\n    }\n}\n\nimpl<H: Char> MatcherDataView<'_, H> {\n    fn setup<const INDICES: bool, N: Char>(\n        &mut self,\n        needle: &[N],\n        mut prev_class: CharClass,\n        config: &Config,\n        start: u32,\n    ) -> bool\n    where\n        H: PartialEq<N>,\n    {\n        let mut row_iter = needle.iter().copied().zip(self.row_offs.iter_mut());\n        let (mut needle_char, mut row_start) = row_iter.next().unwrap();\n\n        let col_iter = self\n            .haystack\n            .iter_mut()\n            .zip(self.bonus.iter_mut())\n            .enumerate();\n\n        let mut matched = false;\n        for (i, (c_, bonus_)) in col_iter {\n            let (c, class) = c_.char_class_and_normalize(config);\n            *c_ = c;\n\n            let bonus = config.bonus_for(prev_class, class);\n            // save bonus for later so we don't have to recompute it each time\n            *bonus_ = bonus as u8;\n            prev_class = class;\n\n            let i = i as u16;\n            if c == needle_char {\n                // save the first idx of each char\n                if let Some(next) = row_iter.next() {\n                    *row_start = i;\n                    (needle_char, row_start) = next;\n                } else if !matched {\n                    *row_start = i;\n                    // we have at least one match\n                    matched = true;\n                }\n            }\n        }\n        if !matched {\n            return false;\n        }\n        debug_assert_eq!(self.row_offs[0], 0);\n        Self::score_row::<true, INDICES, _>(\n            self.current_row,\n            self.matrix_cells,\n            self.haystack,\n            self.bonus,\n            0,\n            self.row_offs[1],\n            0,\n            needle[0],\n            needle[1],\n            if config.prefer_prefix {\n                if start == 0 {\n                    MAX_PREFIX_BONUS * PREFIX_BONUS_SCALE\n                } else {\n                    (MAX_PREFIX_BONUS * PREFIX_BONUS_SCALE - PENALTY_GAP_START).saturating_sub(\n                        (start - 1).min(u16::MAX as u32) as u16 * PENALTY_GAP_EXTENSION,\n                    )\n                }\n            } else {\n                0\n            },\n        );\n        true\n    }\n\n    #[allow(clippy::too_many_arguments)]\n    fn score_row<const FIRST_ROW: bool, const INDICES: bool, N: Char>(\n        current_row: &mut [ScoreCell],\n        matrix_cells: &mut [MatrixCell],\n        haystack: &[H],\n        bonus: &[u8],\n        row_off: u16,\n        mut next_row_off: u16,\n        needle_idx: u16,\n        needle_char: N,\n        next_needle_char: N,\n        mut prefix_bonus: u16,\n    ) where\n        H: PartialEq<N>,\n    {\n        next_row_off -= 1;\n        let relative_row_off = row_off - needle_idx;\n        let next_relative_row_off = next_row_off - needle_idx;\n        let skipped_col_iter = haystack[row_off as usize..next_row_off as usize]\n            .iter()\n            .zip(bonus[row_off as usize..next_row_off as usize].iter())\n            .zip(current_row[relative_row_off as usize..next_relative_row_off as usize].iter_mut())\n            .zip(matrix_cells.iter_mut());\n        let mut prev_p_score = 0;\n        let mut prev_m_score = 0;\n        for (((&c, bonus), score_cell), matrix_cell) in skipped_col_iter {\n            let (p_score, p_matched) = p_score(prev_p_score, prev_m_score);\n            let m_cell = if FIRST_ROW {\n                let cell = if c == needle_char {\n                    ScoreCell {\n                        score: *bonus as u16 * BONUS_FIRST_CHAR_MULTIPLIER\n                            + SCORE_MATCH\n                            + prefix_bonus / PREFIX_BONUS_SCALE,\n                        matched: false,\n                        consecutive_bonus: *bonus,\n                    }\n                } else {\n                    UNMATCHED\n                };\n                prefix_bonus = prefix_bonus.saturating_sub(PENALTY_GAP_EXTENSION);\n                cell\n            } else {\n                *score_cell\n            };\n            if INDICES {\n                matrix_cell.set(p_matched, m_cell.matched);\n            }\n            prev_p_score = p_score;\n            prev_m_score = m_cell.score;\n        }\n        let col_iter = haystack[next_row_off as usize..]\n            .windows(2)\n            .zip(bonus[next_row_off as usize..].windows(2))\n            .zip(current_row[next_relative_row_off as usize..].iter_mut())\n            .zip(matrix_cells[(next_relative_row_off - relative_row_off) as usize..].iter_mut());\n        for (((c, bonus), score_cell), matrix_cell) in col_iter {\n            let (p_score, p_matched) = p_score(prev_p_score, prev_m_score);\n            let m_cell = if FIRST_ROW {\n                let cell = if c[0] == needle_char {\n                    ScoreCell {\n                        score: bonus[0] as u16 * BONUS_FIRST_CHAR_MULTIPLIER\n                            + SCORE_MATCH\n                            + prefix_bonus / PREFIX_BONUS_SCALE,\n                        matched: false,\n                        consecutive_bonus: bonus[0],\n                    }\n                } else {\n                    UNMATCHED\n                };\n                prefix_bonus = prefix_bonus.saturating_sub(PENALTY_GAP_EXTENSION);\n                cell\n            } else {\n                *score_cell\n            };\n            *score_cell = if c[1] == next_needle_char {\n                next_m_cell(p_score, bonus[1] as u16, m_cell)\n            } else {\n                UNMATCHED\n            };\n            if INDICES {\n                matrix_cell.set(p_matched, m_cell.matched);\n            }\n            prev_p_score = p_score;\n            prev_m_score = m_cell.score;\n        }\n    }\n\n    fn populate_matrix<const INDICES: bool, N: Char>(&mut self, needle: &[N]) -> usize\n    where\n        H: PartialEq<N>,\n    {\n        let mut matrix_cells = &mut self.matrix_cells[self.current_row.len()..];\n        let mut row_iter = needle[1..]\n            .iter()\n            .copied()\n            .zip(self.row_offs[1..].iter().copied())\n            .enumerate();\n        let (mut needle_idx, (mut needle_char, mut row_off)) = row_iter.next().unwrap();\n        for (next_needle_idx, (next_needle_char, next_row_off)) in row_iter {\n            Self::score_row::<false, INDICES, _>(\n                self.current_row,\n                matrix_cells,\n                self.haystack,\n                self.bonus,\n                row_off,\n                next_row_off,\n                needle_idx as u16 + 1,\n                needle_char,\n                next_needle_char,\n                0,\n            );\n            let len = self.current_row.len() + needle_idx + 1 - row_off as usize;\n            matrix_cells = &mut matrix_cells[len..];\n            (needle_idx, needle_char, row_off) = (next_needle_idx, next_needle_char, next_row_off);\n        }\n        matrix_cells.as_ptr() as usize - self.matrix_cells.as_ptr() as usize\n    }\n\n    fn reconstruct_optimal_path(\n        &self,\n        max_score_end: u16,\n        indices: &mut Vec<u32>,\n        matrix_len: usize,\n        start: u32,\n    ) {\n        let indices_start = indices.len();\n        indices.resize(indices_start + self.row_offs.len(), 0);\n        let indices = &mut indices[indices_start..];\n        let last_row_off = *self.row_offs.last().unwrap();\n        indices[self.row_offs.len() - 1] = start + max_score_end as u32 + last_row_off as u32;\n\n        let mut matrix_cells = &self.matrix_cells[..matrix_len];\n        let width = self.current_row.len();\n        let mut row_iter = self.row_offs[..self.row_offs.len() - 1]\n            .iter()\n            .copied()\n            .enumerate()\n            .rev()\n            .map(|(i, off)| {\n                let relative_off = off as usize - i;\n                let row;\n                (matrix_cells, row) =\n                    matrix_cells.split_at(matrix_cells.len() - (width - relative_off));\n                (i, off, row)\n            });\n        let (mut row_idx, mut row_off, mut row) = row_iter.next().unwrap();\n        let mut col = max_score_end;\n        let relative_last_row_off = last_row_off as usize + 1 - self.row_offs.len();\n        let mut matched = self.current_row[col as usize + relative_last_row_off].matched;\n        col += last_row_off - row_off - 1;\n        loop {\n            if matched {\n                indices[row_idx] = start + col as u32 + row_off as u32;\n            }\n            let next_matched = row[col as usize].get(matched);\n            if matched {\n                let Some((next_row_idx, next_row_off, next_row)) = row_iter.next() else {\n                    break;\n                };\n                col += row_off - next_row_off;\n                (row_idx, row_off, row) = (next_row_idx, next_row_off, next_row)\n            }\n            col -= 1;\n            matched = next_matched;\n        }\n    }\n}\n"
  },
  {
    "path": "crates/atuin-nucleo/matcher/src/lib.rs",
    "content": "#![allow(clippy::needless_return, mismatched_lifetime_syntaxes)]\n\n/*!\n`atuin_nucleo_matcher` is a low level crate that contains the matcher implementation\nused by the high level `nucleo` crate.\n\n**NOTE**: If you are building an fzf-like interactive fuzzy finder that is\nmeant to match a reasonably large number of items (> 100) using the high level\n`nucleo` crate is highly recommended. Using `nucleo-matcher` directly in you ui\nloop will be very slow. Implementing this logic yourself is very complex.\n\nThe matcher is hightly optimized and can significantly outperform `fzf` and\n`skim` (the `fuzzy-matcher` crate). However some of these optimizations require\na slightly less convenient API. Be sure to carefully read the documentation of\nthe [`Matcher`] to avoid unexpected behaviour.\n# Examples\n\nFor almost all usecases the [`pattern`] API should be used instead of calling\nthe matcher methods directly. [`Pattern::parse`](pattern::Pattern::parse) will\nconstruct a single Atom (a single match operation) for each word. The pattern\ncan contain special characters to control what kind of match is performed (see\n[`AtomKind`](crate::pattern::AtomKind)).\n\n```\n# use atuin_nucleo_matcher::{Matcher, Config};\n# use atuin_nucleo_matcher::pattern::{Pattern, Normalization, CaseMatching};\nlet paths = [\"foo/bar\", \"bar/foo\", \"foobar\"];\nlet mut matcher = Matcher::new(Config::DEFAULT.match_paths());\nlet matches = Pattern::parse(\"foo bar\", CaseMatching::Ignore, Normalization::Smart).match_list(paths, &mut matcher);\nassert_eq!(matches, vec![(\"foo/bar\", 168), (\"bar/foo\", 168), (\"foobar\", 140)]);\nlet matches = Pattern::parse(\"^foo bar\", CaseMatching::Ignore, Normalization::Smart).match_list(paths, &mut matcher);\nassert_eq!(matches, vec![(\"foo/bar\", 168), (\"foobar\", 140)]);\n```\n\nIf the pattern should be matched literally (without this special parsing)\n[`Pattern::new`](pattern::Pattern::new) can be used instead.\n\n```\n# use atuin_nucleo_matcher::{Matcher, Config};\n# use atuin_nucleo_matcher::pattern::{Pattern, CaseMatching, AtomKind, Normalization};\nlet paths = [\"foo/bar\", \"bar/foo\", \"foobar\"];\nlet mut matcher = Matcher::new(Config::DEFAULT.match_paths());\nlet matches = Pattern::new(\"foo bar\", CaseMatching::Ignore, Normalization::Smart, AtomKind::Fuzzy).match_list(paths, &mut matcher);\nassert_eq!(matches, vec![(\"foo/bar\", 168), (\"bar/foo\", 168), (\"foobar\", 140)]);\nlet paths = [\"^foo/bar\", \"bar/^foo\", \"foobar\"];\nlet matches = Pattern::new(\"^foo bar\", CaseMatching::Ignore, Normalization::Smart, AtomKind::Fuzzy).match_list(paths, &mut matcher);\nassert_eq!(matches, vec![(\"^foo/bar\", 188), (\"bar/^foo\", 188)]);\n```\n\nWord segmentation is performed automatically on any unescaped character for which [`is_whitespace`](char::is_whitespace) returns true.\nThis is relevant, for instance, with non-english keyboard input.\n\n```\n# use atuin_nucleo_matcher::pattern::{Atom, Pattern, Normalization, CaseMatching};\nassert_eq!(\n    // double-width 'Ideographic Space', i.e. `'\\u{3000}'`\n    Pattern::parse(\"ほげ　ふが\", CaseMatching::Smart, Normalization::Smart).atoms,\n    vec![\n        Atom::parse(\"ほげ\", CaseMatching::Smart, Normalization::Smart),\n        Atom::parse(\"ふが\", CaseMatching::Smart, Normalization::Smart),\n    ],\n);\n```\n\nIf word segmentation is also not desired, a single `Atom` can be constructed directly.\n\n```\n# use atuin_nucleo_matcher::{Matcher, Config};\n# use atuin_nucleo_matcher::pattern::{Pattern, Atom, CaseMatching, Normalization, AtomKind};\nlet paths = [\"foobar\", \"foo bar\"];\nlet mut matcher = Matcher::new(Config::DEFAULT);\nlet matches = Atom::new(\"foo bar\", CaseMatching::Ignore, Normalization::Smart, AtomKind::Fuzzy, false).match_list(paths, &mut matcher);\nassert_eq!(matches, vec![(\"foo bar\", 192)]);\n```\n\n\n# Status\n\nNucleo is used in the helix-editor and therefore has a large user base with lots or real world testing. The core matcher implementation is considered complete and is unlikely to see major changes. The `nucleo-matcher` crate is finished and ready for widespread use, breaking changes should be very rare (a 1.0 release should not be far away).\n\n*/\n\n// sadly ranges don't optmimzie well\n#![allow(clippy::manual_range_contains)]\n#![warn(missing_docs)]\n\npub mod chars;\nmod config;\n#[cfg(test)]\nmod debug;\nmod exact;\nmod fuzzy_greedy;\nmod fuzzy_optimal;\nmod matrix;\npub mod pattern;\nmod prefilter;\nmod score;\nmod utf32_str;\n\n#[cfg(test)]\nmod tests;\n\npub use crate::config::Config;\npub use crate::utf32_str::{Utf32Str, Utf32String};\n\nuse crate::chars::{AsciiChar, Char};\nuse crate::matrix::MatrixSlab;\n\n/// A matcher engine that can execute (fuzzy) matches.\n///\n/// A matches contains **heap allocated** scratch memory that is reused during\n/// matching. This scratch memory allows the matcher to guarantee that it will\n/// **never allocate** during matching (with the exception of pushing to the\n/// `indices` vector if there isn't enough capacity). However this scratch\n/// memory is fairly large (around 135KB) so creating a matcher is expensive.\n///\n/// All `.._match` functions will not compute the indices  of the matched\n/// characters. These should be used to prefilter to filter and rank all\n/// matches. All `.._indices` functions will also compute the indices of the\n/// matched characters but are slower compared to the `..match` variant. These\n/// should be used when rendering the best N matches. Note that the `indices`\n/// argument is **never cleared**. This allows running multiple different\n/// matches on the same haystack and merging the indices by sorting and\n/// deduplicating the vector.\n///\n/// The `needle` argument for each function must always be normalized by the\n/// caller (unicode normalization and case folding). Otherwise, the matcher\n/// may fail to produce a match. The [`pattern`] modules provides utilities\n/// to preprocess needles and **should usually be preferred over invoking the\n/// matcher directly**.  Additionally it's recommend to perform separate matches\n/// for each word in the needle. Consider the folloling example:\n///\n/// If `foo bar` is used as the needle it matches both `foo test baaar` and\n/// `foo hello-world bar`. However, `foo test baaar` will receive a higher\n/// score than `foo hello-world bar`. `baaar` contains a 2 character gap which\n/// will receive a penalty and therefore the user will likely expect it to rank\n/// lower. However, if `foo bar` is matched as a single query `hello-world` and\n/// `test` are both considered gaps too. As `hello-world` is a much longer gap\n/// then `test` the extra penalty for `baaar` is canceled out. If both words\n/// are matched individually the interspersed words do not receive a penalty and\n/// `foo hello-world bar` ranks higher.\n///\n/// In general nucleo is a **substring matching tool** (except for the prefix/\n/// postfix matching modes) with no penalty assigned to matches that start\n/// later within the same pattern (which enables matching words individually\n/// as shown above). If patterns show a large variety in length and the syntax\n/// described above is not used it may be preferable to give preference to\n/// matches closer to the start of a haystack. To accommodate that usecase the\n/// [`prefer_prefix`](Config::prefer_prefix) option can be set to true.\n///\n/// Matching is limited to 2^32-1 codepoints, if the haystack is longer than\n/// that the matcher **will panic**. The caller must decide whether it wants to\n/// filter out long haystacks or truncate them.\npub struct Matcher {\n    #[allow(missing_docs)]\n    pub config: Config,\n    slab: MatrixSlab,\n}\n\n// this is just here for convenience not sure if we should implement this\nimpl Clone for Matcher {\n    fn clone(&self) -> Self {\n        Matcher {\n            config: self.config.clone(),\n            slab: MatrixSlab::new(),\n        }\n    }\n}\n\nimpl std::fmt::Debug for Matcher {\n    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n        f.debug_struct(\"Matcher\")\n            .field(\"config\", &self.config)\n            .finish_non_exhaustive()\n    }\n}\n\nimpl Default for Matcher {\n    fn default() -> Self {\n        Matcher {\n            config: Config::DEFAULT,\n            slab: MatrixSlab::new(),\n        }\n    }\n}\n\nimpl Matcher {\n    /// Creates a new matcher instance, note that this will eagerly allocate a\n    /// fairly large chunk of heap memory (around 135KB currently but subject to\n    /// change) so matchers should be reused if called often (like in a loop).\n    pub fn new(config: Config) -> Self {\n        Self {\n            config,\n            slab: MatrixSlab::new(),\n        }\n    }\n\n    /// Find the fuzzy match with the highest score in the `haystack`.\n    ///\n    /// This functions has `O(mn)` time complexity for short inputs.\n    /// To avoid slowdowns it automatically falls back to\n    /// [greedy matching](crate::Matcher::fuzzy_match_greedy) for large\n    /// needles and haystacks.\n    ///\n    /// See the [matcher documentation](crate::Matcher) for more details.\n    pub fn fuzzy_match(&mut self, haystack: Utf32Str<'_>, needle: Utf32Str<'_>) -> Option<u16> {\n        assert!(haystack.len() <= u32::MAX as usize);\n        self.fuzzy_matcher_impl::<false>(haystack, needle, &mut Vec::new())\n    }\n\n    /// Find the fuzzy match with the highest score in the `haystack` and\n    /// compute its indices.\n    ///\n    /// This functions has `O(mn)` time complexity for short inputs. To\n    /// avoid slowdowns it automatically falls back to\n    /// [greedy matching](crate::Matcher::fuzzy_match_greedy) for large needles\n    /// and haystacks\n    ///\n    /// See the [matcher documentation](crate::Matcher) for more details.\n    pub fn fuzzy_indices(\n        &mut self,\n        haystack: Utf32Str<'_>,\n        needle: Utf32Str<'_>,\n        indices: &mut Vec<u32>,\n    ) -> Option<u16> {\n        assert!(haystack.len() <= u32::MAX as usize);\n        self.fuzzy_matcher_impl::<true>(haystack, needle, indices)\n    }\n\n    fn fuzzy_matcher_impl<const INDICES: bool>(\n        &mut self,\n        haystack_: Utf32Str<'_>,\n        needle_: Utf32Str<'_>,\n        indices: &mut Vec<u32>,\n    ) -> Option<u16> {\n        if needle_.len() > haystack_.len() {\n            return None;\n        }\n        if needle_.is_empty() {\n            return Some(0);\n        }\n        if needle_.len() == haystack_.len() {\n            return self.exact_match_impl::<INDICES>(\n                haystack_,\n                needle_,\n                0,\n                haystack_.len(),\n                indices,\n            );\n        }\n        assert!(\n            haystack_.len() <= u32::MAX as usize,\n            \"fuzzy matching is only support for up to 2^32-1 codepoints\"\n        );\n        match (haystack_, needle_) {\n            (Utf32Str::Ascii(haystack), Utf32Str::Ascii(needle)) => {\n                if let &[needle] = needle {\n                    return self.substring_match_1_ascii::<INDICES>(haystack, needle, indices);\n                }\n                let (start, greedy_end, end) = self.prefilter_ascii(haystack, needle, false)?;\n                if needle_.len() == end - start {\n                    return Some(self.calculate_score::<INDICES, _, _>(\n                        AsciiChar::cast(haystack),\n                        AsciiChar::cast(needle),\n                        start,\n                        greedy_end,\n                        indices,\n                    ));\n                }\n                self.fuzzy_match_optimal::<INDICES, AsciiChar, AsciiChar>(\n                    AsciiChar::cast(haystack),\n                    AsciiChar::cast(needle),\n                    start,\n                    greedy_end,\n                    end,\n                    indices,\n                )\n            }\n            (Utf32Str::Ascii(_), Utf32Str::Unicode(_)) => {\n                // a purely ascii haystack can never be transformed to match\n                // a needle that contains non-ascii chars since we don't allow gaps\n                None\n            }\n            (Utf32Str::Unicode(haystack), Utf32Str::Ascii(needle)) => {\n                if let &[needle] = needle {\n                    let (start, _) = self.prefilter_non_ascii(haystack, needle_, true)?;\n                    let res = self.substring_match_1_non_ascii::<INDICES>(\n                        haystack,\n                        needle as char,\n                        start,\n                        indices,\n                    );\n                    return Some(res);\n                }\n                let (start, end) = self.prefilter_non_ascii(haystack, needle_, false)?;\n                if needle_.len() == end - start {\n                    return self\n                        .exact_match_impl::<INDICES>(haystack_, needle_, start, end, indices);\n                }\n                self.fuzzy_match_optimal::<INDICES, char, AsciiChar>(\n                    haystack,\n                    AsciiChar::cast(needle),\n                    start,\n                    start + 1,\n                    end,\n                    indices,\n                )\n            }\n            (Utf32Str::Unicode(haystack), Utf32Str::Unicode(needle)) => {\n                if let &[needle] = needle {\n                    let (start, _) = self.prefilter_non_ascii(haystack, needle_, true)?;\n                    let res = self\n                        .substring_match_1_non_ascii::<INDICES>(haystack, needle, start, indices);\n                    return Some(res);\n                }\n                let (start, end) = self.prefilter_non_ascii(haystack, needle_, false)?;\n                if needle_.len() == end - start {\n                    return self\n                        .exact_match_impl::<INDICES>(haystack_, needle_, start, end, indices);\n                }\n                self.fuzzy_match_optimal::<INDICES, char, char>(\n                    haystack,\n                    needle,\n                    start,\n                    start + 1,\n                    end,\n                    indices,\n                )\n            }\n        }\n    }\n\n    /// Greedly find a fuzzy match in the `haystack`.\n    ///\n    /// This functions has `O(n)` time complexity but may provide unintutive (non-optimal)\n    /// indices and scores. Usually [fuzzy_match](crate::Matcher::fuzzy_match) should\n    /// be preferred.\n    ///\n    /// See the [matcher documentation](crate::Matcher) for more details.\n    pub fn fuzzy_match_greedy(\n        &mut self,\n        haystack: Utf32Str<'_>,\n        needle: Utf32Str<'_>,\n    ) -> Option<u16> {\n        assert!(haystack.len() <= u32::MAX as usize);\n        self.fuzzy_match_greedy_impl::<false>(haystack, needle, &mut Vec::new())\n    }\n\n    /// Greedly find a fuzzy match in the `haystack` and compute its indices.\n    ///\n    /// This functions has `O(n)` time complexity but may provide unintuitive (non-optimal)\n    /// indices and scores. Usually [fuzzy_indices](crate::Matcher::fuzzy_indices) should\n    /// be preferred.\n    ///\n    /// See the [matcher documentation](crate::Matcher) for more details.\n    pub fn fuzzy_indices_greedy(\n        &mut self,\n        haystack: Utf32Str<'_>,\n        needle: Utf32Str<'_>,\n        indices: &mut Vec<u32>,\n    ) -> Option<u16> {\n        assert!(haystack.len() <= u32::MAX as usize);\n        self.fuzzy_match_greedy_impl::<true>(haystack, needle, indices)\n    }\n\n    fn fuzzy_match_greedy_impl<const INDICES: bool>(\n        &mut self,\n        haystack: Utf32Str<'_>,\n        needle_: Utf32Str<'_>,\n        indices: &mut Vec<u32>,\n    ) -> Option<u16> {\n        if needle_.len() > haystack.len() {\n            return None;\n        }\n        if needle_.is_empty() {\n            return Some(0);\n        }\n        if needle_.len() == haystack.len() {\n            return self.exact_match_impl::<INDICES>(haystack, needle_, 0, haystack.len(), indices);\n        }\n        assert!(\n            haystack.len() <= u32::MAX as usize,\n            \"matching is only support for up to 2^32-1 codepoints\"\n        );\n        match (haystack, needle_) {\n            (Utf32Str::Ascii(haystack), Utf32Str::Ascii(needle)) => {\n                let (start, greedy_end, _) = self.prefilter_ascii(haystack, needle, true)?;\n                if needle_.len() == greedy_end - start {\n                    return Some(self.calculate_score::<INDICES, _, _>(\n                        AsciiChar::cast(haystack),\n                        AsciiChar::cast(needle),\n                        start,\n                        greedy_end,\n                        indices,\n                    ));\n                }\n                self.fuzzy_match_greedy_::<INDICES, AsciiChar, AsciiChar>(\n                    AsciiChar::cast(haystack),\n                    AsciiChar::cast(needle),\n                    start,\n                    greedy_end,\n                    indices,\n                )\n            }\n            (Utf32Str::Ascii(_), Utf32Str::Unicode(_)) => {\n                // a purely ascii haystack can never be transformed to match\n                // a needle that contains non-ascii chars since we don't allow gaps\n                None\n            }\n            (Utf32Str::Unicode(haystack), Utf32Str::Ascii(needle)) => {\n                let (start, _) = self.prefilter_non_ascii(haystack, needle_, true)?;\n                self.fuzzy_match_greedy_::<INDICES, char, AsciiChar>(\n                    haystack,\n                    AsciiChar::cast(needle),\n                    start,\n                    start + 1,\n                    indices,\n                )\n            }\n            (Utf32Str::Unicode(haystack), Utf32Str::Unicode(needle)) => {\n                let (start, _) = self.prefilter_non_ascii(haystack, needle_, true)?;\n                self.fuzzy_match_greedy_::<INDICES, char, char>(\n                    haystack,\n                    needle,\n                    start,\n                    start + 1,\n                    indices,\n                )\n            }\n        }\n    }\n\n    /// Finds the substring match with the highest score in the `haystack`.\n    ///\n    /// This functions has `O(nm)` time complexity. However many cases can\n    /// be significantly accelerated using prefilters so it's usually very fast\n    /// in practice.\n    ///\n    /// See the [matcher documentation](crate::Matcher) for more details.\n    pub fn substring_match(\n        &mut self,\n        haystack: Utf32Str<'_>,\n        needle_: Utf32Str<'_>,\n    ) -> Option<u16> {\n        self.substring_match_impl::<false>(haystack, needle_, &mut Vec::new())\n    }\n\n    /// Finds the substring match with the highest score in the `haystack` and\n    /// compute its indices.\n    ///\n    /// This functions has `O(nm)` time complexity. However many cases can\n    /// be significantly accelerated using prefilters so it's usually fast\n    /// in practice.\n    ///\n    /// See the [matcher documentation](crate::Matcher) for more details.\n    pub fn substring_indices(\n        &mut self,\n        haystack: Utf32Str<'_>,\n        needle_: Utf32Str<'_>,\n        indices: &mut Vec<u32>,\n    ) -> Option<u16> {\n        self.substring_match_impl::<true>(haystack, needle_, indices)\n    }\n\n    fn substring_match_impl<const INDICES: bool>(\n        &mut self,\n        haystack: Utf32Str<'_>,\n        needle_: Utf32Str<'_>,\n        indices: &mut Vec<u32>,\n    ) -> Option<u16> {\n        if needle_.len() > haystack.len() {\n            return None;\n        }\n        if needle_.is_empty() {\n            return Some(0);\n        }\n        if needle_.len() == haystack.len() {\n            return self.exact_match_impl::<INDICES>(haystack, needle_, 0, haystack.len(), indices);\n        }\n        assert!(\n            haystack.len() <= u32::MAX as usize,\n            \"matching is only support for up to 2^32-1 codepoints\"\n        );\n        match (haystack, needle_) {\n            (Utf32Str::Ascii(haystack), Utf32Str::Ascii(needle)) => {\n                if let &[needle] = needle {\n                    return self.substring_match_1_ascii::<INDICES>(haystack, needle, indices);\n                }\n                self.substring_match_ascii::<INDICES>(haystack, needle, indices)\n            }\n            (Utf32Str::Ascii(_), Utf32Str::Unicode(_)) => {\n                // a purely ascii haystack can never be transformed to match\n                // a needle that contains non-ascii chars since we don't allow gaps\n                None\n            }\n            (Utf32Str::Unicode(haystack), Utf32Str::Ascii(needle)) => {\n                if let &[needle] = needle {\n                    let (start, _) = self.prefilter_non_ascii(haystack, needle_, true)?;\n                    let res = self.substring_match_1_non_ascii::<INDICES>(\n                        haystack,\n                        needle as char,\n                        start,\n                        indices,\n                    );\n                    return Some(res);\n                }\n                let (start, _) = self.prefilter_non_ascii(haystack, needle_, false)?;\n                self.substring_match_non_ascii::<INDICES, _>(\n                    haystack,\n                    AsciiChar::cast(needle),\n                    start,\n                    indices,\n                )\n            }\n            (Utf32Str::Unicode(haystack), Utf32Str::Unicode(needle)) => {\n                if let &[needle] = needle {\n                    let (start, _) = self.prefilter_non_ascii(haystack, needle_, true)?;\n                    let res = self\n                        .substring_match_1_non_ascii::<INDICES>(haystack, needle, start, indices);\n                    return Some(res);\n                }\n                let (start, _) = self.prefilter_non_ascii(haystack, needle_, false)?;\n                self.substring_match_non_ascii::<INDICES, _>(haystack, needle, start, indices)\n            }\n        }\n    }\n\n    /// Checks whether needle and haystack match exactly.\n    ///\n    /// This functions has `O(n)` time complexity.\n    ///\n    /// See the [matcher documentation](crate::Matcher) for more details.\n    pub fn exact_match(&mut self, haystack: Utf32Str<'_>, needle: Utf32Str<'_>) -> Option<u16> {\n        if needle.is_empty() {\n            return Some(0);\n        }\n        let mut leading_space = 0;\n        let mut trailing_space = 0;\n        if !needle.first().is_whitespace() {\n            leading_space = haystack.leading_white_space()\n        }\n        if !needle.last().is_whitespace() {\n            trailing_space = haystack.trailing_white_space()\n        }\n        // avoid wraparound in size check\n        if trailing_space == haystack.len() {\n            return None;\n        }\n        self.exact_match_impl::<false>(\n            haystack,\n            needle,\n            leading_space,\n            haystack.len() - trailing_space,\n            &mut Vec::new(),\n        )\n    }\n\n    /// Checks whether needle and haystack match exactly and compute the matches indices.\n    ///\n    /// This functions has `O(n)` time complexity.\n    ///\n    /// See the [matcher documentation](crate::Matcher) for more details.\n    pub fn exact_indices(\n        &mut self,\n        haystack: Utf32Str<'_>,\n        needle: Utf32Str<'_>,\n        indices: &mut Vec<u32>,\n    ) -> Option<u16> {\n        if needle.is_empty() {\n            return Some(0);\n        }\n        let mut leading_space = 0;\n        let mut trailing_space = 0;\n        if !needle.first().is_whitespace() {\n            leading_space = haystack.leading_white_space()\n        }\n        if !needle.last().is_whitespace() {\n            trailing_space = haystack.trailing_white_space()\n        }\n        // avoid wraparound in size check\n        if trailing_space == haystack.len() {\n            return None;\n        }\n        self.exact_match_impl::<true>(\n            haystack,\n            needle,\n            leading_space,\n            haystack.len() - trailing_space,\n            indices,\n        )\n    }\n\n    /// Checks whether needle is a prefix of the haystack.\n    ///\n    /// This functions has `O(n)` time complexity.\n    ///\n    /// See the [matcher documentation](crate::Matcher) for more details.\n    pub fn prefix_match(&mut self, haystack: Utf32Str<'_>, needle: Utf32Str<'_>) -> Option<u16> {\n        if needle.is_empty() {\n            return Some(0);\n        }\n        let mut leading_space = 0;\n        if !needle.first().is_whitespace() {\n            leading_space = haystack.leading_white_space()\n        }\n        if haystack.len() - leading_space < needle.len() {\n            None\n        } else {\n            self.exact_match_impl::<false>(\n                haystack,\n                needle,\n                leading_space,\n                needle.len() + leading_space,\n                &mut Vec::new(),\n            )\n        }\n    }\n\n    /// Checks whether needle is a prefix of the haystack and compute the matches indices.\n    ///\n    /// This functions has `O(n)` time complexity.\n    ///\n    /// See the [matcher documentation](crate::Matcher) for more details.\n    pub fn prefix_indices(\n        &mut self,\n        haystack: Utf32Str<'_>,\n        needle: Utf32Str<'_>,\n        indices: &mut Vec<u32>,\n    ) -> Option<u16> {\n        if needle.is_empty() {\n            return Some(0);\n        }\n        let mut leading_space = 0;\n        if !needle.first().is_whitespace() {\n            leading_space = haystack.leading_white_space()\n        }\n        if haystack.len() - leading_space < needle.len() {\n            None\n        } else {\n            self.exact_match_impl::<true>(\n                haystack,\n                needle,\n                leading_space,\n                needle.len() + leading_space,\n                indices,\n            )\n        }\n    }\n\n    /// Checks whether needle is a postfix of the haystack.\n    ///\n    /// This functions has `O(n)` time complexity.\n    ///\n    /// See the [matcher documentation](crate::Matcher) for more details.\n    pub fn postfix_match(&mut self, haystack: Utf32Str<'_>, needle: Utf32Str<'_>) -> Option<u16> {\n        if needle.is_empty() {\n            return Some(0);\n        }\n        let mut trailing_spaces = 0;\n        if !needle.last().is_whitespace() {\n            trailing_spaces = haystack.trailing_white_space()\n        }\n        if haystack.len() - trailing_spaces < needle.len() {\n            None\n        } else {\n            self.exact_match_impl::<false>(\n                haystack,\n                needle,\n                haystack.len() - needle.len() - trailing_spaces,\n                haystack.len() - trailing_spaces,\n                &mut Vec::new(),\n            )\n        }\n    }\n\n    /// Checks whether needle is a postfix of the haystack and compute the matches indices.\n    ///\n    /// This functions has `O(n)` time complexity.\n    ///\n    /// See the [matcher documentation](crate::Matcher) for more details.\n    pub fn postfix_indices(\n        &mut self,\n        haystack: Utf32Str<'_>,\n        needle: Utf32Str<'_>,\n        indices: &mut Vec<u32>,\n    ) -> Option<u16> {\n        if needle.is_empty() {\n            return Some(0);\n        }\n        let mut trailing_spaces = 0;\n        if !needle.last().is_whitespace() {\n            trailing_spaces = haystack.trailing_white_space()\n        }\n        if haystack.len() - trailing_spaces < needle.len() {\n            None\n        } else {\n            self.exact_match_impl::<true>(\n                haystack,\n                needle,\n                haystack.len() - needle.len() - trailing_spaces,\n                haystack.len() - trailing_spaces,\n                indices,\n            )\n        }\n    }\n\n    fn exact_match_impl<const INDICES: bool>(\n        &mut self,\n        haystack: Utf32Str<'_>,\n        needle_: Utf32Str<'_>,\n        start: usize,\n        end: usize,\n        indices: &mut Vec<u32>,\n    ) -> Option<u16> {\n        if needle_.len() != end - start {\n            return None;\n        }\n        assert!(\n            haystack.len() <= u32::MAX as usize,\n            \"matching is only support for up to 2^32-1 codepoints\"\n        );\n        let score = match (haystack, needle_) {\n            (Utf32Str::Ascii(haystack), Utf32Str::Ascii(needle)) => {\n                let matched = if self.config.ignore_case {\n                    AsciiChar::cast(haystack)[start..end]\n                        .iter()\n                        .map(|c| c.normalize(&self.config))\n                        .eq(AsciiChar::cast(needle)\n                            .iter()\n                            .map(|c| c.normalize(&self.config)))\n                } else {\n                    &haystack[start..end] == needle\n                };\n                if !matched {\n                    return None;\n                }\n                self.calculate_score::<INDICES, _, _>(\n                    AsciiChar::cast(haystack),\n                    AsciiChar::cast(needle),\n                    start,\n                    end,\n                    indices,\n                )\n            }\n            (Utf32Str::Ascii(_), Utf32Str::Unicode(_)) => {\n                // a purely ascii haystack can never be transformed to match\n                // a needle that contains non-ascii chars since we don't allow gaps\n                return None;\n            }\n            (Utf32Str::Unicode(haystack), Utf32Str::Ascii(needle)) => {\n                let matched = haystack[start..end]\n                    .iter()\n                    .map(|c| c.normalize(&self.config))\n                    .eq(AsciiChar::cast(needle)\n                        .iter()\n                        .map(|c| c.normalize(&self.config)));\n                if !matched {\n                    return None;\n                }\n\n                self.calculate_score::<INDICES, _, _>(\n                    haystack,\n                    AsciiChar::cast(needle),\n                    start,\n                    end,\n                    indices,\n                )\n            }\n            (Utf32Str::Unicode(haystack), Utf32Str::Unicode(needle)) => {\n                let matched = haystack[start..end]\n                    .iter()\n                    .map(|c| c.normalize(&self.config))\n                    .eq(needle.iter().map(|c| c.normalize(&self.config)));\n                if !matched {\n                    return None;\n                }\n                self.calculate_score::<INDICES, _, _>(haystack, needle, start, end, indices)\n            }\n        };\n        Some(score)\n    }\n}\n"
  },
  {
    "path": "crates/atuin-nucleo/matcher/src/matrix.rs",
    "content": "use std::alloc::{alloc_zeroed, dealloc, handle_alloc_error, Layout};\nuse std::marker::PhantomData;\nuse std::mem::size_of;\nuse std::panic::{RefUnwindSafe, UnwindSafe};\nuse std::ptr::{slice_from_raw_parts_mut, NonNull};\n\nuse crate::chars::Char;\n\nconst MAX_MATRIX_SIZE: usize = 100 * 1024; // 100*1024 = 100KB\n\n// these two aren't hard maxima, instead we simply allow whatever will fit into memory\nconst MAX_HAYSTACK_LEN: usize = 2048; // 64KB\nconst MAX_NEEDLE_LEN: usize = 2048; // 64KB\n\nstruct MatrixLayout<C: Char> {\n    haystack_len: usize,\n    needle_len: usize,\n    layout: Layout,\n    haystack_off: usize,\n    bonus_off: usize,\n    rows_off: usize,\n    score_off: usize,\n    matrix_off: usize,\n    _phantom: PhantomData<C>,\n}\nimpl<C: Char> MatrixLayout<C> {\n    fn new(haystack_len: usize, needle_len: usize) -> MatrixLayout<C> {\n        assert!(haystack_len >= needle_len);\n        assert!(haystack_len <= u32::MAX as usize);\n        let mut layout = Layout::from_size_align(0, 1).unwrap();\n        let haystack_layout = Layout::array::<C>(haystack_len).unwrap();\n        let bonus_layout = Layout::array::<u8>(haystack_len).unwrap();\n        let rows_layout = Layout::array::<u16>(needle_len).unwrap();\n        let score_layout = Layout::array::<ScoreCell>(haystack_len + 1 - needle_len).unwrap();\n        let matrix_layout =\n            Layout::array::<MatrixCell>((haystack_len + 1 - needle_len) * needle_len).unwrap();\n\n        let haystack_off;\n        (layout, haystack_off) = layout.extend(haystack_layout).unwrap();\n        let bonus_off;\n        (layout, bonus_off) = layout.extend(bonus_layout).unwrap();\n        let rows_off;\n        (layout, rows_off) = layout.extend(rows_layout).unwrap();\n        let score_off;\n        (layout, score_off) = layout.extend(score_layout).unwrap();\n        let matrix_off;\n        (layout, matrix_off) = layout.extend(matrix_layout).unwrap();\n        MatrixLayout {\n            haystack_len,\n            needle_len,\n            layout,\n            haystack_off,\n            bonus_off,\n            rows_off,\n            score_off,\n            matrix_off,\n            _phantom: PhantomData,\n        }\n    }\n    /// # Safety\n    ///\n    /// `ptr` must point at an allocated with MARTIX_ALLOC_LAYOUT\n    #[allow(clippy::type_complexity)]\n    unsafe fn fieds_from_ptr(\n        &self,\n        ptr: NonNull<u8>,\n    ) -> (\n        *mut [C],\n        *mut [u8],\n        *mut [u16],\n        *mut [ScoreCell],\n        *mut [MatrixCell],\n    ) {\n        let base = ptr.as_ptr();\n        let haystack = base.add(self.haystack_off) as *mut C;\n        let haystack = slice_from_raw_parts_mut(haystack, self.haystack_len);\n        let bonus = base.add(self.bonus_off);\n        let bonus = slice_from_raw_parts_mut(bonus, self.haystack_len);\n        let rows = base.add(self.rows_off) as *mut u16;\n        let rows = slice_from_raw_parts_mut(rows, self.needle_len);\n        let cells = base.add(self.score_off) as *mut ScoreCell;\n        let cells = slice_from_raw_parts_mut(cells, self.haystack_len + 1 - self.needle_len);\n        let matrix = base.add(self.matrix_off) as *mut MatrixCell;\n        let matrix = slice_from_raw_parts_mut(\n            matrix,\n            (self.haystack_len + 1 - self.needle_len) * self.haystack_len,\n        );\n        (haystack, bonus, rows, cells, matrix)\n    }\n}\n\nconst _SIZE_CHECK: () = {\n    if size_of::<ScoreCell>() != 8 {\n        panic!()\n    }\n};\n\n// make this act like a u64\n#[repr(align(8))]\n#[derive(Clone, Copy, PartialEq, Eq)]\npub(crate) struct ScoreCell {\n    pub score: u16,\n    pub consecutive_bonus: u8,\n    pub matched: bool,\n}\n\npub(crate) struct MatcherDataView<'a, C: Char> {\n    pub haystack: &'a mut [C],\n    // stored as a separate array instead of struct\n    // to avoid padding since char is too large and u8 too small :/\n    pub bonus: &'a mut [u8],\n    pub current_row: &'a mut [ScoreCell],\n    pub row_offs: &'a mut [u16],\n    pub matrix_cells: &'a mut [MatrixCell],\n}\n#[repr(transparent)]\npub struct MatrixCell(pub(crate) u8);\n\nimpl MatrixCell {\n    pub fn set(&mut self, p_match: bool, m_match: bool) {\n        self.0 = p_match as u8 | ((m_match as u8) << 1);\n    }\n\n    pub fn get(&self, m_matrix: bool) -> bool {\n        let mask = m_matrix as u8 + 1;\n        (self.0 & mask) != 0\n    }\n}\n\n// we only use this to construct the layout for the slab allocation\n#[allow(unused)]\nstruct MatcherData {\n    haystack: [char; MAX_HAYSTACK_LEN],\n    bonus: [u8; MAX_HAYSTACK_LEN],\n    row_offs: [u16; MAX_NEEDLE_LEN],\n    scratch_space: [ScoreCell; MAX_HAYSTACK_LEN],\n    matrix: [u8; MAX_MATRIX_SIZE],\n}\n\npub(crate) struct MatrixSlab(NonNull<u8>);\nunsafe impl Sync for MatrixSlab {}\nunsafe impl Send for MatrixSlab {}\nimpl UnwindSafe for MatrixSlab {}\nimpl RefUnwindSafe for MatrixSlab {}\n\nimpl MatrixSlab {\n    pub fn new() -> Self {\n        let layout = Layout::new::<MatcherData>();\n        // safety: the matrix is never zero sized (hardcoded constants)\n        let ptr = unsafe { alloc_zeroed(layout) };\n        let Some(ptr) = NonNull::new(ptr) else {\n            handle_alloc_error(layout)\n        };\n        MatrixSlab(ptr.cast())\n    }\n\n    pub(crate) fn alloc<C: Char>(\n        &mut self,\n        haystack_: &[C],\n        needle_len: usize,\n    ) -> Option<MatcherDataView<'_, C>> {\n        let cells = haystack_.len() * needle_len;\n        if cells > MAX_MATRIX_SIZE\n            || haystack_.len() > u16::MAX as usize\n            // ensures that scores never overflow\n            || needle_len > MAX_NEEDLE_LEN\n        {\n            return None;\n        }\n        let matrix_layout = MatrixLayout::<C>::new(haystack_.len(), needle_len);\n        if matrix_layout.layout.size() > size_of::<MatcherData>() {\n            return None;\n        }\n        unsafe {\n            // safely: this allocation is valid for MATRIX_ALLOC_LAYOUT\n            let (haystack, bonus, rows, current_row, matrix_cells) =\n                matrix_layout.fieds_from_ptr(self.0);\n            // copy haystack before creating references to ensure we don't create\n            // references to invalid chars (which may or may not be UB)\n            haystack_\n                .as_ptr()\n                .copy_to_nonoverlapping(haystack as *mut _, haystack_.len());\n            Some(MatcherDataView {\n                haystack: &mut *haystack,\n                row_offs: &mut *rows,\n                bonus: &mut *bonus,\n                current_row: &mut *current_row,\n                matrix_cells: &mut *matrix_cells,\n            })\n        }\n    }\n}\n\nimpl Drop for MatrixSlab {\n    fn drop(&mut self) {\n        unsafe { dealloc(self.0.as_ptr(), Layout::new::<MatcherData>()) };\n    }\n}\n"
  },
  {
    "path": "crates/atuin-nucleo/matcher/src/pattern/tests.rs",
    "content": "use crate::pattern::{Atom, AtomKind, CaseMatching, Normalization, Pattern};\n\n#[test]\nfn negative() {\n    let pat = Atom::parse(\"!foo\", CaseMatching::Smart, Normalization::Smart);\n    assert!(pat.negative);\n    assert_eq!(pat.kind, AtomKind::Substring);\n    assert_eq!(pat.needle.to_string(), \"foo\");\n    let pat = Atom::parse(\"!^foo\", CaseMatching::Smart, Normalization::Smart);\n    assert!(pat.negative);\n    assert_eq!(pat.kind, AtomKind::Prefix);\n    assert_eq!(pat.needle.to_string(), \"foo\");\n    let pat = Atom::parse(\"!foo$\", CaseMatching::Smart, Normalization::Smart);\n    assert!(pat.negative);\n    assert_eq!(pat.kind, AtomKind::Postfix);\n    assert_eq!(pat.needle.to_string(), \"foo\");\n    let pat = Atom::parse(\"!^foo$\", CaseMatching::Smart, Normalization::Smart);\n    assert!(pat.negative);\n    assert_eq!(pat.kind, AtomKind::Exact);\n    assert_eq!(pat.needle.to_string(), \"foo\");\n}\n\n#[test]\nfn pattern_kinds() {\n    let pat = Atom::parse(\"foo\", CaseMatching::Smart, Normalization::Smart);\n    assert!(!pat.negative);\n    assert_eq!(pat.kind, AtomKind::Fuzzy);\n    assert_eq!(pat.needle.to_string(), \"foo\");\n    let pat = Atom::parse(\"'foo\", CaseMatching::Smart, Normalization::Smart);\n    assert!(!pat.negative);\n    assert_eq!(pat.kind, AtomKind::Substring);\n    assert_eq!(pat.needle.to_string(), \"foo\");\n    let pat = Atom::parse(\"^foo\", CaseMatching::Smart, Normalization::Smart);\n    assert!(!pat.negative);\n    assert_eq!(pat.kind, AtomKind::Prefix);\n    assert_eq!(pat.needle.to_string(), \"foo\");\n    let pat = Atom::parse(\"foo$\", CaseMatching::Smart, Normalization::Smart);\n    assert!(!pat.negative);\n    assert_eq!(pat.kind, AtomKind::Postfix);\n    assert_eq!(pat.needle.to_string(), \"foo\");\n    let pat = Atom::parse(\"^foo$\", CaseMatching::Smart, Normalization::Smart);\n    assert!(!pat.negative);\n    assert_eq!(pat.kind, AtomKind::Exact);\n    assert_eq!(pat.needle.to_string(), \"foo\");\n}\n\n#[test]\nfn case_matching() {\n    let pat = Atom::parse(\"foo\", CaseMatching::Smart, Normalization::Smart);\n    assert!(pat.ignore_case);\n    assert_eq!(pat.needle.to_string(), \"foo\");\n    let pat = Atom::parse(\"Foo\", CaseMatching::Smart, Normalization::Smart);\n    assert!(!pat.ignore_case);\n    assert_eq!(pat.needle.to_string(), \"Foo\");\n    let pat = Atom::parse(\"Foo\", CaseMatching::Ignore, Normalization::Smart);\n    assert!(pat.ignore_case);\n    assert_eq!(pat.needle.to_string(), \"foo\");\n    let pat = Atom::parse(\"Foo\", CaseMatching::Respect, Normalization::Smart);\n    assert!(!pat.ignore_case);\n    assert_eq!(pat.needle.to_string(), \"Foo\");\n    let pat = Atom::parse(\"Foo\", CaseMatching::Respect, Normalization::Smart);\n    assert!(!pat.ignore_case);\n    assert_eq!(pat.needle.to_string(), \"Foo\");\n    let pat = Atom::parse(\"Äxx\", CaseMatching::Ignore, Normalization::Smart);\n    assert!(pat.ignore_case);\n    assert_eq!(pat.needle.to_string(), \"äxx\");\n    let pat = Atom::parse(\"Äxx\", CaseMatching::Respect, Normalization::Smart);\n    assert!(!pat.ignore_case);\n    let pat = Atom::parse(\"Axx\", CaseMatching::Smart, Normalization::Smart);\n    assert!(!pat.ignore_case);\n    assert_eq!(pat.needle.to_string(), \"Axx\");\n    let pat = Atom::parse(\"你xx\", CaseMatching::Smart, Normalization::Smart);\n    assert!(pat.ignore_case);\n    assert_eq!(pat.needle.to_string(), \"你xx\");\n    let pat = Atom::parse(\"你xx\", CaseMatching::Ignore, Normalization::Smart);\n    assert!(pat.ignore_case);\n    assert_eq!(pat.needle.to_string(), \"你xx\");\n    let pat = Atom::parse(\"Ⲽxx\", CaseMatching::Smart, Normalization::Smart);\n    assert!(!pat.ignore_case);\n    assert_eq!(pat.needle.to_string(), \"Ⲽxx\");\n    let pat = Atom::parse(\"Ⲽxx\", CaseMatching::Ignore, Normalization::Smart);\n    assert!(pat.ignore_case);\n    assert_eq!(pat.needle.to_string(), \"ⲽxx\");\n}\n\n#[test]\nfn escape() {\n    let pat = Atom::parse(\"foo\\\\ bar\", CaseMatching::Smart, Normalization::Smart);\n    assert_eq!(pat.needle.to_string(), \"foo bar\");\n    let pat = Atom::parse(\"\\\\!foo\", CaseMatching::Smart, Normalization::Smart);\n    assert_eq!(pat.needle.to_string(), \"!foo\");\n    assert_eq!(pat.kind, AtomKind::Fuzzy);\n    let pat = Atom::parse(\"\\\\'foo\", CaseMatching::Smart, Normalization::Smart);\n    assert_eq!(pat.needle.to_string(), \"'foo\");\n    assert_eq!(pat.kind, AtomKind::Fuzzy);\n    let pat = Atom::parse(\"\\\\^foo\", CaseMatching::Smart, Normalization::Smart);\n    assert_eq!(pat.needle.to_string(), \"^foo\");\n    assert_eq!(pat.kind, AtomKind::Fuzzy);\n    let pat = Atom::parse(\"foo\\\\$\", CaseMatching::Smart, Normalization::Smart);\n    assert_eq!(pat.needle.to_string(), \"foo$\");\n    assert_eq!(pat.kind, AtomKind::Fuzzy);\n    let pat = Atom::parse(\"^foo\\\\$\", CaseMatching::Smart, Normalization::Smart);\n    assert_eq!(pat.needle.to_string(), \"foo$\");\n    assert_eq!(pat.kind, AtomKind::Prefix);\n    let pat = Atom::parse(\"\\\\^foo\\\\$\", CaseMatching::Smart, Normalization::Smart);\n    assert_eq!(pat.needle.to_string(), \"^foo$\");\n    assert_eq!(pat.kind, AtomKind::Fuzzy);\n    let pat = Atom::parse(\"\\\\!^foo\\\\$\", CaseMatching::Smart, Normalization::Smart);\n    assert_eq!(pat.needle.to_string(), \"!^foo$\");\n    assert_eq!(pat.kind, AtomKind::Fuzzy);\n    let pat = Atom::parse(\"!\\\\^foo\\\\$\", CaseMatching::Smart, Normalization::Smart);\n    assert_eq!(pat.needle.to_string(), \"^foo$\");\n    assert_eq!(pat.kind, AtomKind::Substring);\n}\n\n#[test]\nfn pattern_atoms() {\n    assert_eq!(\n        Pattern::parse(\"a b\", CaseMatching::Ignore, Normalization::Smart).atoms,\n        vec![\n            Atom::parse(\"a\", CaseMatching::Ignore, Normalization::Smart),\n            Atom::parse(\"b\", CaseMatching::Ignore, Normalization::Smart),\n        ]\n    );\n\n    assert_eq!(\n        Pattern::parse(\"a\\n b\", CaseMatching::Ignore, Normalization::Smart).atoms,\n        vec![\n            Atom::parse(\"a\", CaseMatching::Ignore, Normalization::Smart),\n            Atom::parse(\"b\", CaseMatching::Ignore, Normalization::Smart),\n        ]\n    );\n\n    assert_eq!(\n        Pattern::parse(\"  a b\\r\\n\", CaseMatching::Ignore, Normalization::Smart).atoms,\n        vec![\n            Atom::parse(\"a\", CaseMatching::Ignore, Normalization::Smart),\n            Atom::parse(\"b\", CaseMatching::Ignore, Normalization::Smart),\n        ]\n    );\n\n    assert_eq!(\n        Pattern::parse(\"ほ　げ\", CaseMatching::Smart, Normalization::Smart).atoms,\n        vec![\n            Atom::parse(\"ほ\", CaseMatching::Smart, Normalization::Smart),\n            Atom::parse(\"げ\", CaseMatching::Smart, Normalization::Smart),\n        ],\n    )\n}\n"
  },
  {
    "path": "crates/atuin-nucleo/matcher/src/pattern.rs",
    "content": "//! This module provides a slightly higher level API for matching strings.\n\nuse std::cmp::Reverse;\n\nuse crate::{chars, Matcher, Utf32Str};\n\n#[cfg(test)]\nmod tests;\n\nuse crate::Utf32String;\n\n#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]\n#[non_exhaustive]\n/// How to treat a case mismatch between two characters.\npub enum CaseMatching {\n    /// Characters never match their case folded version (`a != A`).\n    #[cfg_attr(not(feature = \"unicode-casefold\"), default)]\n    Respect,\n    /// Characters always match their case folded version (`a == A`).\n    #[cfg(feature = \"unicode-casefold\")]\n    Ignore,\n    /// Acts like [`Ignore`](CaseMatching::Ignore) if all characters in a pattern atom are\n    /// lowercase and like [`Respect`](CaseMatching::Respect) otherwise.\n    #[default]\n    #[cfg(feature = \"unicode-casefold\")]\n    Smart,\n}\n\n#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]\n#[non_exhaustive]\n/// How to handle unicode normalization,\npub enum Normalization {\n    /// Characters never match their normalized version (`a != ä`).\n    #[cfg_attr(not(feature = \"unicode-normalization\"), default)]\n    Never,\n    /// Acts like [`Never`](Normalization::Never) if any character in a pattern atom\n    /// would need to be normalized. Otherwise normalization occurs (`a == ä` but `ä != a`).\n    #[default]\n    #[cfg(feature = \"unicode-normalization\")]\n    Smart,\n}\n\n#[derive(Debug, PartialEq, Eq, Clone, Copy)]\n#[non_exhaustive]\n/// The kind of matching algorithm to run for an atom.\npub enum AtomKind {\n    /// Fuzzy matching where the needle must match any haystack characters\n    /// (match can contain gaps). This atom kind is used by default if no\n    /// special syntax is used. There is no negated fuzzy matching (too\n    /// many false positives).\n    ///\n    /// See also [`Matcher::fuzzy_match`](crate::Matcher::fuzzy_match).\n    Fuzzy,\n    /// The needle must match a contiguous sequence of haystack characters\n    /// without gaps.  This atom kind is parsed from the following syntax:\n    /// `'foo` and `!foo` (negated).\n    ///\n    /// See also [`Matcher::substring_match`](crate::Matcher::substring_match).\n    Substring,\n    /// The needle must match all leading haystack characters without gaps or\n    /// prefix. This atom kind is parsed from the following syntax: `^foo` and\n    /// `!^foo` (negated).\n    ///\n    /// See also [`Matcher::prefix_match`](crate::Matcher::prefix_match).\n    Prefix,\n    /// The needle must match all trailing haystack characters without gaps or\n    /// postfix. This atom kind is parsed from the following syntax: `foo$` and\n    /// `!foo$` (negated).\n    ///\n    /// See also [`Matcher::postfix_match`](crate::Matcher::postfix_match).\n    Postfix,\n    /// The needle must match all haystack characters without gaps or prefix.\n    /// This atom kind is parsed from the following syntax: `^foo$` and `!^foo$`\n    /// (negated).\n    ///\n    /// See also [`Matcher::exact_match`](crate::Matcher::exact_match).\n    Exact,\n}\n\n/// A single pattern component that is matched with a single [`Matcher`] function\n#[derive(Debug, PartialEq, Eq, Clone)]\npub struct Atom {\n    /// Whether this pattern atom is a negative match.\n    /// A negative pattern atom will prevent haystacks matching it from\n    /// being matchend. It does not contribute to scoring/indices\n    pub negative: bool,\n    /// The kind of match that this pattern performs\n    pub kind: AtomKind,\n    needle: Utf32String,\n    ignore_case: bool,\n    normalize: bool,\n}\n\nimpl Atom {\n    /// Creates a single [`Atom`] from a string by performing unicode\n    /// normalization and case folding (if necessary). Optionally `\\ ` can\n    /// be escaped to ` `.\n    pub fn new(\n        needle: &str,\n        case: CaseMatching,\n        normalize: Normalization,\n        kind: AtomKind,\n        escape_whitespace: bool,\n    ) -> Atom {\n        Atom::new_inner(needle, case, normalize, kind, escape_whitespace, false)\n    }\n\n    fn new_inner(\n        needle: &str,\n        case: CaseMatching,\n        normalization: Normalization,\n        kind: AtomKind,\n        escape_whitespace: bool,\n        append_dollar: bool,\n    ) -> Atom {\n        let mut ignore_case;\n        let mut normalize;\n        #[cfg(feature = \"unicode-normalization\")]\n        {\n            normalize = matches!(normalization, Normalization::Smart);\n        }\n        #[cfg(not(feature = \"unicode-normalization\"))]\n        {\n            normalize = false;\n        }\n        let needle = if needle.is_ascii() {\n            let mut needle = if escape_whitespace {\n                if let Some((start, rem)) = needle.split_once(\"\\\\ \") {\n                    let mut needle = start.to_owned();\n                    for rem in rem.split(\"\\\\ \") {\n                        needle.push(' ');\n                        needle.push_str(rem);\n                    }\n                    needle\n                } else {\n                    needle.to_owned()\n                }\n            } else {\n                needle.to_owned()\n            };\n\n            match case {\n                #[cfg(feature = \"unicode-casefold\")]\n                CaseMatching::Ignore => {\n                    ignore_case = true;\n                    needle.make_ascii_lowercase()\n                }\n                #[cfg(feature = \"unicode-casefold\")]\n                CaseMatching::Smart => {\n                    ignore_case = !needle.bytes().any(|b| b.is_ascii_uppercase())\n                }\n                CaseMatching::Respect => ignore_case = false,\n            }\n            if append_dollar {\n                needle.push('$');\n            }\n            Utf32String::Ascii(needle.into_boxed_str())\n        } else {\n            let mut needle_ = Vec::with_capacity(needle.len());\n            #[cfg(feature = \"unicode-casefold\")]\n            {\n                ignore_case = matches!(case, CaseMatching::Ignore | CaseMatching::Smart);\n            }\n            #[cfg(not(feature = \"unicode-casefold\"))]\n            {\n                ignore_case = false;\n            }\n            #[cfg(feature = \"unicode-normalization\")]\n            {\n                normalize = matches!(normalization, Normalization::Smart);\n            }\n            if escape_whitespace {\n                let mut saw_backslash = false;\n                for mut c in chars::graphemes(needle) {\n                    if saw_backslash {\n                        if c == ' ' {\n                            needle_.push(' ');\n                            saw_backslash = false;\n                            continue;\n                        } else {\n                            needle_.push('\\\\');\n                        }\n                    }\n                    saw_backslash = c == '\\\\';\n                    match case {\n                        #[cfg(feature = \"unicode-casefold\")]\n                        CaseMatching::Ignore => c = chars::to_lower_case(c),\n                        #[cfg(feature = \"unicode-casefold\")]\n                        CaseMatching::Smart => {\n                            ignore_case = ignore_case && !chars::is_upper_case(c)\n                        }\n                        CaseMatching::Respect => (),\n                    }\n                    match normalization {\n                        #[cfg(feature = \"unicode-normalization\")]\n                        Normalization::Smart => {\n                            normalize = normalize && chars::normalize(c) == c;\n                        }\n                        Normalization::Never => (),\n                    }\n                    needle_.push(c);\n                }\n            } else {\n                let chars = chars::graphemes(needle).map(|mut c| {\n                    match case {\n                        #[cfg(feature = \"unicode-casefold\")]\n                        CaseMatching::Ignore => c = chars::to_lower_case(c),\n                        #[cfg(feature = \"unicode-casefold\")]\n                        CaseMatching::Smart => {\n                            ignore_case = ignore_case && !chars::is_upper_case(c);\n                        }\n                        CaseMatching::Respect => (),\n                    }\n                    match normalization {\n                        #[cfg(feature = \"unicode-normalization\")]\n                        Normalization::Smart => {\n                            normalize = normalize && chars::normalize(c) == c;\n                        }\n                        Normalization::Never => (),\n                    }\n                    c\n                });\n                needle_.extend(chars);\n            };\n            if append_dollar {\n                needle_.push('$');\n            }\n            Utf32String::Unicode(needle_.into_boxed_slice())\n        };\n        Atom {\n            kind,\n            needle,\n            negative: false,\n            ignore_case,\n            normalize,\n        }\n    }\n\n    /// Parse a pattern atom from a string. Some special trailing and leading\n    /// characters can be used to control the atom kind. See [`AtomKind`] for\n    /// details.\n    pub fn parse(raw: &str, case: CaseMatching, normalize: Normalization) -> Atom {\n        let mut atom = raw;\n        let invert = match atom.as_bytes() {\n            [b'!', ..] => {\n                atom = &atom[1..];\n                true\n            }\n            [b'\\\\', b'!', ..] => {\n                atom = &atom[1..];\n                false\n            }\n            _ => false,\n        };\n\n        let mut kind = match atom.as_bytes() {\n            [b'^', ..] => {\n                atom = &atom[1..];\n                AtomKind::Prefix\n            }\n            [b'\\'', ..] => {\n                atom = &atom[1..];\n                AtomKind::Substring\n            }\n            [b'\\\\', b'^' | b'\\'', ..] => {\n                atom = &atom[1..];\n                AtomKind::Fuzzy\n            }\n            _ => AtomKind::Fuzzy,\n        };\n\n        let mut append_dollar = false;\n        match atom.as_bytes() {\n            [.., b'\\\\', b'$'] => {\n                append_dollar = true;\n                atom = &atom[..atom.len() - 2]\n            }\n            [.., b'$'] => {\n                kind = if kind == AtomKind::Fuzzy {\n                    AtomKind::Postfix\n                } else {\n                    AtomKind::Exact\n                };\n                atom = &atom[..atom.len() - 1]\n            }\n            _ => (),\n        }\n\n        if invert && kind == AtomKind::Fuzzy {\n            kind = AtomKind::Substring\n        }\n\n        let mut pattern = Atom::new_inner(atom, case, normalize, kind, true, append_dollar);\n        pattern.negative = invert;\n        pattern\n    }\n\n    /// Matches this pattern against `haystack` (using the allocation and configuration\n    /// from `matcher`) and calculates a ranking score. See the [`Matcher`].\n    /// Documentation for more details.\n    ///\n    /// *Note:*  The `ignore_case` setting is overwritten to match the casing of\n    /// each pattern atom.\n    pub fn score(&self, haystack: Utf32Str<'_>, matcher: &mut Matcher) -> Option<u16> {\n        matcher.config.ignore_case = self.ignore_case;\n        matcher.config.normalize = self.normalize;\n        let pattern_score = match self.kind {\n            AtomKind::Exact => matcher.exact_match(haystack, self.needle.slice(..)),\n            AtomKind::Fuzzy => matcher.fuzzy_match(haystack, self.needle.slice(..)),\n            AtomKind::Substring => matcher.substring_match(haystack, self.needle.slice(..)),\n            AtomKind::Prefix => matcher.prefix_match(haystack, self.needle.slice(..)),\n            AtomKind::Postfix => matcher.postfix_match(haystack, self.needle.slice(..)),\n        };\n        if self.negative {\n            if pattern_score.is_some() {\n                return None;\n            }\n            Some(0)\n        } else {\n            pattern_score\n        }\n    }\n\n    /// Matches this pattern against `haystack` (using the allocation and\n    /// configuration from `matcher`), calculates a ranking score and the match\n    /// indices. See the [`Matcher`]. Documentation for more\n    /// details.\n    ///\n    /// *Note:*  The `ignore_case` setting is overwritten to match the casing of\n    /// each pattern atom.\n    ///\n    /// *Note:*  The `indices` vector is not cleared by this function.\n    pub fn indices(\n        &self,\n        haystack: Utf32Str<'_>,\n        matcher: &mut Matcher,\n        indices: &mut Vec<u32>,\n    ) -> Option<u16> {\n        matcher.config.ignore_case = self.ignore_case;\n        matcher.config.normalize = self.normalize;\n        if self.negative {\n            let pattern_score = match self.kind {\n                AtomKind::Exact => matcher.exact_match(haystack, self.needle.slice(..)),\n                AtomKind::Fuzzy => matcher.fuzzy_match(haystack, self.needle.slice(..)),\n                AtomKind::Substring => matcher.substring_match(haystack, self.needle.slice(..)),\n                AtomKind::Prefix => matcher.prefix_match(haystack, self.needle.slice(..)),\n                AtomKind::Postfix => matcher.postfix_match(haystack, self.needle.slice(..)),\n            };\n            pattern_score.is_none().then_some(0)\n        } else {\n            match self.kind {\n                AtomKind::Exact => matcher.exact_indices(haystack, self.needle.slice(..), indices),\n                AtomKind::Fuzzy => matcher.fuzzy_indices(haystack, self.needle.slice(..), indices),\n                AtomKind::Substring => {\n                    matcher.substring_indices(haystack, self.needle.slice(..), indices)\n                }\n                AtomKind::Prefix => {\n                    matcher.prefix_indices(haystack, self.needle.slice(..), indices)\n                }\n                AtomKind::Postfix => {\n                    matcher.postfix_indices(haystack, self.needle.slice(..), indices)\n                }\n            }\n        }\n    }\n\n    /// Returns the needle text that is passed to the matcher. All indices\n    /// produced by the `indices` functions produce char indices used to index\n    /// this text\n    pub fn needle_text(&self) -> Utf32Str<'_> {\n        self.needle.slice(..)\n    }\n    /// Convenience function to easily match (and sort) a (relatively small)\n    /// list of inputs.\n    ///\n    /// *Note* This function is not recommended for building a full fuzzy\n    /// matching application that can match large numbers of matches (like all\n    /// files in a directory) as all matching is done on the current thread,\n    /// effectively blocking the UI. For such applications the high level\n    /// `nucleo` crate can be used instead.\n    pub fn match_list<T: AsRef<str>>(\n        &self,\n        items: impl IntoIterator<Item = T>,\n        matcher: &mut Matcher,\n    ) -> Vec<(T, u16)> {\n        if self.needle.is_empty() {\n            return items.into_iter().map(|item| (item, 0)).collect();\n        }\n        let mut buf = Vec::new();\n        let mut items: Vec<_> = items\n            .into_iter()\n            .filter_map(|item| {\n                self.score(Utf32Str::new(item.as_ref(), &mut buf), matcher)\n                    .map(|score| (item, score))\n            })\n            .collect();\n        items.sort_by_key(|(_, score)| Reverse(*score));\n        items\n    }\n}\n\nfn pattern_atoms(pattern: &str) -> impl Iterator<Item = &str> + '_ {\n    let mut saw_backslash = false;\n    pattern.split(move |c| {\n        saw_backslash = match c {\n            c if c.is_whitespace() && !saw_backslash => return true,\n            '\\\\' => true,\n            _ => false,\n        };\n        false\n    })\n}\n\n#[derive(Debug, Default)]\n/// A text pattern made up of (potentially multiple) [atoms](crate::pattern::Atom).\n#[non_exhaustive]\npub struct Pattern {\n    /// The individual pattern (words) in this pattern\n    pub atoms: Vec<Atom>,\n}\n\nimpl Pattern {\n    /// Creates a pattern where each word is matched individually (whitespaces\n    /// can be escaped with `\\`). Otherwise no parsing is performed (so `$`, `!`,\n    /// `'` and `^` don't receive special treatment). If you want to match the entire\n    /// pattern as a single needle use a single [`Atom`] instead.\n    pub fn new(\n        pattern: &str,\n        case_matching: CaseMatching,\n        normalize: Normalization,\n        kind: AtomKind,\n    ) -> Pattern {\n        let atoms = pattern_atoms(pattern)\n            .filter_map(|pat| {\n                let pat = Atom::new(pat, case_matching, normalize, kind, true);\n                (!pat.needle.is_empty()).then_some(pat)\n            })\n            .collect();\n        Pattern { atoms }\n    }\n    /// Creates a pattern where each word is matched individually (whitespaces\n    /// can be escaped with `\\`). And `$`, `!`, `'` and `^` at word boundaries will\n    /// cause different matching behaviour (see [`AtomKind`]). These can be\n    /// escaped with backslash.\n    pub fn parse(pattern: &str, case_matching: CaseMatching, normalize: Normalization) -> Pattern {\n        let atoms = pattern_atoms(pattern)\n            .filter_map(|pat| {\n                let pat = Atom::parse(pat, case_matching, normalize);\n                (!pat.needle.is_empty()).then_some(pat)\n            })\n            .collect();\n        Pattern { atoms }\n    }\n\n    /// Convenience function to easily match (and sort) a (relatively small)\n    /// list of inputs.\n    ///\n    /// *Note* This function is not recommended for building a full fuzzy\n    /// matching application that can match large numbers of matches (like all\n    /// files in a directory) as all matching is done on the current thread,\n    /// effectively blocking the UI. For such applications the high level\n    /// `nucleo` crate can be used instead.\n    pub fn match_list<T: AsRef<str>>(\n        &self,\n        items: impl IntoIterator<Item = T>,\n        matcher: &mut Matcher,\n    ) -> Vec<(T, u32)> {\n        if self.atoms.is_empty() {\n            return items.into_iter().map(|item| (item, 0)).collect();\n        }\n        let mut buf = Vec::new();\n        let mut items: Vec<_> = items\n            .into_iter()\n            .filter_map(|item| {\n                self.score(Utf32Str::new(item.as_ref(), &mut buf), matcher)\n                    .map(|score| (item, score))\n            })\n            .collect();\n        items.sort_by_key(|(_, score)| Reverse(*score));\n        items\n    }\n\n    /// Matches this pattern against `haystack` (using the allocation and configuration\n    /// from `matcher`) and calculates a ranking score. See the [`Matcher`]\n    /// documentation for more details.\n    ///\n    /// *Note:*  The `ignore_case` setting is overwritten to match the casing of\n    /// each pattern atom.\n    pub fn score(&self, haystack: Utf32Str<'_>, matcher: &mut Matcher) -> Option<u32> {\n        if self.atoms.is_empty() {\n            return Some(0);\n        }\n        let mut score = 0;\n        for pattern in &self.atoms {\n            score += pattern.score(haystack, matcher)? as u32;\n        }\n        Some(score)\n    }\n\n    /// Matches this pattern against `haystack` (using the allocation and\n    /// configuration from `matcher`), calculates a ranking score and the match\n    /// indices. See the [`Matcher`] documentation for more\n    /// details.\n    ///\n    /// *Note:*  The `ignore_case` setting is overwritten to match the casing of\n    /// each pattern atom.\n    ///\n    /// *Note:*  The indices for each pattern are calculated individually\n    /// and simply appended to the `indices` vector and not deduplicated/sorted.\n    /// This allows associating the match indices to their source pattern. If\n    /// required (like for highlighting) unique/sorted indices can be obtained\n    /// as follows:\n    ///\n    /// ```\n    /// # let mut indices: Vec<u32> = Vec::new();\n    /// indices.sort_unstable();\n    /// indices.dedup();\n    /// ```\n    pub fn indices(\n        &self,\n        haystack: Utf32Str<'_>,\n        matcher: &mut Matcher,\n        indices: &mut Vec<u32>,\n    ) -> Option<u32> {\n        if self.atoms.is_empty() {\n            return Some(0);\n        }\n        let mut score = 0;\n        for pattern in &self.atoms {\n            score += pattern.indices(haystack, matcher, indices)? as u32;\n        }\n        Some(score)\n    }\n\n    /// Refreshes this pattern by reparsing it from a string. This is mostly\n    /// equivalent to just constructing a new pattern using [`Pattern::parse`]\n    /// but is slightly more efficient by reusing some allocations\n    pub fn reparse(\n        &mut self,\n        pattern: &str,\n        case_matching: CaseMatching,\n        normalize: Normalization,\n    ) {\n        self.atoms.clear();\n        let atoms = pattern_atoms(pattern).filter_map(|atom| {\n            let atom = Atom::parse(atom, case_matching, normalize);\n            if atom.needle.is_empty() {\n                return None;\n            }\n            Some(atom)\n        });\n        self.atoms.extend(atoms);\n    }\n}\n\nimpl Clone for Pattern {\n    fn clone(&self) -> Self {\n        Self {\n            atoms: self.atoms.clone(),\n        }\n    }\n\n    fn clone_from(&mut self, source: &Self) {\n        self.atoms.clone_from(&source.atoms);\n    }\n}\n"
  },
  {
    "path": "crates/atuin-nucleo/matcher/src/prefilter.rs",
    "content": "use ::memchr::{memchr, memchr2, memrchr, memrchr2};\n\nuse crate::chars::Char;\nuse crate::utf32_str::Utf32Str;\nuse crate::Matcher;\n\n#[inline(always)]\nfn find_ascii_ignore_case(c: u8, haystack: &[u8]) -> Option<usize> {\n    if c >= b'a' && c <= b'z' {\n        memchr2(c, c - 32, haystack)\n    } else {\n        memchr(c, haystack)\n    }\n}\n\n#[inline(always)]\nfn find_ascii_ignore_case_rev(c: u8, haystack: &[u8]) -> Option<usize> {\n    if c >= b'a' && c <= b'z' {\n        memrchr2(c, c - 32, haystack)\n    } else {\n        memrchr(c, haystack)\n    }\n}\n\nimpl Matcher {\n    pub(crate) fn prefilter_ascii(\n        &self,\n        mut haystack: &[u8],\n        needle: &[u8],\n        only_greedy: bool,\n    ) -> Option<(usize, usize, usize)> {\n        if self.config.ignore_case {\n            let start =\n                find_ascii_ignore_case(needle[0], &haystack[..haystack.len() - needle.len() + 1])?;\n            let mut greedy_end = start + 1;\n            haystack = &haystack[greedy_end..];\n            for &c in &needle[1..] {\n                let idx = find_ascii_ignore_case(c, haystack)? + 1;\n                greedy_end += idx;\n                haystack = &haystack[idx..];\n            }\n            if only_greedy {\n                Some((start, greedy_end, greedy_end))\n            } else {\n                let end = greedy_end\n                    + find_ascii_ignore_case_rev(*needle.last().unwrap(), haystack)\n                        .map_or(0, |i| i + 1);\n                Some((start, greedy_end, end))\n            }\n        } else {\n            let start = memchr(needle[0], &haystack[..haystack.len() - needle.len() + 1])?;\n            let mut greedy_end = start + 1;\n            haystack = &haystack[greedy_end..];\n            for &c in &needle[1..] {\n                let idx = memchr(c, haystack)? + 1;\n                greedy_end += idx;\n                haystack = &haystack[idx..];\n            }\n            if only_greedy {\n                Some((start, greedy_end, greedy_end))\n            } else {\n                let end =\n                    greedy_end + memrchr(*needle.last().unwrap(), haystack).map_or(0, |i| i + 1);\n                Some((start, greedy_end, end))\n            }\n        }\n    }\n\n    pub(crate) fn prefilter_non_ascii(\n        &self,\n        haystack: &[char],\n        needle: Utf32Str<'_>,\n        only_greedy: bool,\n    ) -> Option<(usize, usize)> {\n        let needle_char = needle.get(0);\n        let start = haystack[..haystack.len() - needle.len() + 1]\n            .iter()\n            .position(|c| c.normalize(&self.config) == needle_char)?;\n        let needle_char = needle.last();\n        if only_greedy {\n            if haystack.len() - start < needle.len() {\n                return None;\n            }\n            Some((start, start + 1))\n        } else {\n            let end = haystack.len()\n                - haystack[start + 1..]\n                    .iter()\n                    .rev()\n                    .position(|c| c.normalize(&self.config) == needle_char)?;\n            if end - start < needle.len() {\n                return None;\n            }\n\n            Some((start, end))\n        }\n    }\n}\n"
  },
  {
    "path": "crates/atuin-nucleo/matcher/src/score.rs",
    "content": "use std::cmp::max;\n\nuse crate::chars::{Char, CharClass};\nuse crate::{Config, Matcher};\n\npub(crate) const SCORE_MATCH: u16 = 16;\npub(crate) const PENALTY_GAP_START: u16 = 3;\npub(crate) const PENALTY_GAP_EXTENSION: u16 = 1;\n/// If the prefer_prefix option is enabled we want to penalize\n/// the initial gap. The prefix should not be too much  \npub(crate) const PREFIX_BONUS_SCALE: u16 = 2;\npub(crate) const MAX_PREFIX_BONUS: u16 = BONUS_BOUNDARY;\n\n// We prefer matches at the beginning of a word, but the bonus should not be\n// too great to prevent the longer acronym matches from always winning over\n// shorter fuzzy matches. The bonus point here was specifically chosen that\n// the bonus is cancelled when the gap between the acronyms grows over\n// 8 characters, which is approximately the average length of the words found\n// in web2 dictionary and my file system.\npub(crate) const BONUS_BOUNDARY: u16 = SCORE_MATCH / 2;\n\n// Edge-triggered bonus for matches in camelCase words.\n// Their value should be BONUS_BOUNDARY - PENALTY_GAP_EXTENSION = 7.\n// However, this priporitzes camel case over non-camel case.\n// In fzf/skim this is not a problem since they score off the max\n// consecutive bonus. However, we don't do that (because its incorrect)\n// so to avoids prioritizing camel we use a lower bonus. I think that's fine\n// usually camel case is wekaer boundary than actual wourd boundaries anyway\n// This also has the nice sideeffect of perfectly balancing out\n// camel case, snake case and the consecutive version of the word\npub(crate) const BONUS_CAMEL123: u16 = BONUS_BOUNDARY - PENALTY_GAP_START;\n\n/// Although bonus point for non-word characters is non-contextual, we need it\n/// for computing bonus points for consecutive chunks starting with a non-word\n/// character.\npub(crate) const BONUS_NON_WORD: u16 = BONUS_BOUNDARY;\n\n// Minimum bonus point given to characters in consecutive chunks.\n// Note that bonus points for consecutive matches shouldn't have needed if we\n// used fixed match score as in the original algorithm.\npub(crate) const BONUS_CONSECUTIVE: u16 = PENALTY_GAP_START + PENALTY_GAP_EXTENSION;\n\n// The first character in the typed pattern usually has more significance\n// than the rest so it's important that it appears at special positions where\n// bonus points are given, e.g. \"to-go\" vs. \"ongoing\" on \"og\" or on \"ogo\".\n// The amount of the extra bonus should be limited so that the gap penalty is\n// still respected.\npub(crate) const BONUS_FIRST_CHAR_MULTIPLIER: u16 = 2;\n\nimpl Config {\n    #[inline]\n    pub(crate) fn bonus_for(&self, prev_class: CharClass, class: CharClass) -> u16 {\n        if class > CharClass::Delimiter {\n            // transition from non word to word\n            match prev_class {\n                CharClass::Whitespace => return self.bonus_boundary_white,\n                CharClass::Delimiter => return self.bonus_boundary_delimiter,\n                CharClass::NonWord => return BONUS_BOUNDARY,\n                _ => (),\n            }\n        }\n        if prev_class == CharClass::Lower && class == CharClass::Upper\n            || prev_class != CharClass::Number && class == CharClass::Number\n        {\n            // camelCase letter123\n            BONUS_CAMEL123\n        } else if class == CharClass::Whitespace {\n            self.bonus_boundary_white\n        } else if class == CharClass::NonWord {\n            return BONUS_NON_WORD;\n        } else {\n            0\n        }\n    }\n}\nimpl Matcher {\n    #[inline(always)]\n    pub(crate) fn bonus_for(&self, prev_class: CharClass, class: CharClass) -> u16 {\n        self.config.bonus_for(prev_class, class)\n    }\n\n    pub(crate) fn calculate_score<const INDICES: bool, H: Char + PartialEq<N>, N: Char>(\n        &mut self,\n        haystack: &[H],\n        needle: &[N],\n        start: usize,\n        end: usize,\n        indices: &mut Vec<u32>,\n    ) -> u16 {\n        if INDICES {\n            indices.reserve(needle.len());\n        }\n\n        let mut prev_class = start\n            .checked_sub(1)\n            .map(|i| haystack[i].char_class(&self.config))\n            .unwrap_or(self.config.initial_char_class);\n        let mut needle_iter = needle.iter();\n        let mut needle_char = *needle_iter.next().unwrap();\n\n        let mut in_gap = false;\n        let mut consecutive = 1;\n\n        // unrolled the first iteration to make applying the first char multiplier less awkward\n        if INDICES {\n            indices.push(start as u32)\n        }\n        let class = haystack[start].char_class(&self.config);\n        let mut first_bonus = self.bonus_for(prev_class, class);\n        let mut score = SCORE_MATCH + first_bonus * BONUS_FIRST_CHAR_MULTIPLIER;\n        prev_class = class;\n        needle_char = *needle_iter.next().unwrap_or(&needle_char);\n\n        for (i, c) in haystack[start + 1..end].iter().enumerate() {\n            let (c, class) = c.char_class_and_normalize(&self.config);\n            if c == needle_char {\n                if INDICES {\n                    indices.push(i as u32 + start as u32 + 1)\n                }\n                let mut bonus = self.bonus_for(prev_class, class);\n                if consecutive != 0 {\n                    if bonus >= BONUS_BOUNDARY && bonus > first_bonus {\n                        first_bonus = bonus\n                    }\n                    bonus = max(max(bonus, first_bonus), BONUS_CONSECUTIVE);\n                } else {\n                    first_bonus = bonus;\n                }\n                score += SCORE_MATCH + bonus;\n                in_gap = false;\n                consecutive += 1;\n                if let Some(&next) = needle_iter.next() {\n                    needle_char = next;\n                }\n            } else {\n                let penalty = if in_gap {\n                    PENALTY_GAP_EXTENSION\n                } else {\n                    PENALTY_GAP_START\n                };\n                score = score.saturating_sub(penalty);\n                in_gap = true;\n                consecutive = 0;\n            }\n            prev_class = class;\n        }\n        if self.config.prefer_prefix {\n            if start != 0 {\n                let penalty = PENALTY_GAP_START\n                    + PENALTY_GAP_START * (start - 1).min(u16::MAX as usize) as u16;\n                score += MAX_PREFIX_BONUS.saturating_sub(penalty / PREFIX_BONUS_SCALE);\n            } else {\n                score += MAX_PREFIX_BONUS;\n            }\n        }\n        score\n    }\n}\n"
  },
  {
    "path": "crates/atuin-nucleo/matcher/src/tests.rs",
    "content": "use crate::chars::Char;\nuse crate::pattern::{CaseMatching, Normalization, Pattern};\nuse crate::score::{\n    BONUS_BOUNDARY, BONUS_CAMEL123, BONUS_CONSECUTIVE, BONUS_FIRST_CHAR_MULTIPLIER, BONUS_NON_WORD,\n    MAX_PREFIX_BONUS, PENALTY_GAP_EXTENSION, PENALTY_GAP_START, SCORE_MATCH,\n};\nuse crate::utf32_str::Utf32Str;\nuse crate::{Config, Matcher};\n\nuse Algorithm::*;\n\n#[derive(Debug)]\nenum Algorithm {\n    FuzzyOptimal,\n    FuzzyGreedy,\n    Substring,\n    Prefix,\n    Postfix,\n    Exact,\n}\n\nfn assert_matches(\n    algorithm: &[Algorithm],\n    normalize: bool,\n    case_sensitive: bool,\n    path: bool,\n    prefer_prefix: bool,\n    cases: &[(&str, &str, &[u32], u16)],\n) {\n    let mut config = Config {\n        normalize,\n        ignore_case: !case_sensitive,\n        prefer_prefix,\n        ..Config::DEFAULT\n    };\n    if path {\n        config.set_match_paths();\n    }\n    let mut matcher = Matcher::new(config);\n    let mut matched_indices = Vec::new();\n    let mut needle_buf = Vec::new();\n    let mut haystack_buf = Vec::new();\n    for &(haystack, needle, indices, mut score) in cases {\n        let needle = if !case_sensitive {\n            needle.to_lowercase()\n        } else {\n            needle.to_owned()\n        };\n        let needle = Utf32Str::new(&needle, &mut needle_buf);\n        let haystack = Utf32Str::new(haystack, &mut haystack_buf);\n        score += needle.len() as u16 * SCORE_MATCH;\n        for algo in algorithm {\n            println!(\"xx {matched_indices:?} {algo:?}\");\n            matched_indices.clear();\n            let res = match algo {\n                FuzzyOptimal => matcher.fuzzy_indices(haystack, needle, &mut matched_indices),\n                FuzzyGreedy => matcher.fuzzy_indices_greedy(haystack, needle, &mut matched_indices),\n                Substring => matcher.substring_indices(haystack, needle, &mut matched_indices),\n                Prefix => matcher.prefix_indices(haystack, needle, &mut matched_indices),\n                Postfix => matcher.postfix_indices(haystack, needle, &mut matched_indices),\n                Exact => matcher.exact_indices(haystack, needle, &mut matched_indices),\n            };\n            println!(\"{matched_indices:?}\");\n            let match_chars: Vec<_> = matched_indices\n                .iter()\n                .map(|&i| haystack.get(i).normalize(&matcher.config))\n                .collect();\n            let needle_chars: Vec<_> = needle.chars().collect();\n\n            assert_eq!(\n                res,\n                Some(score),\n                \"{needle:?} did  not match {haystack:?}: matched {match_chars:?} {matched_indices:?} {algo:?}\"\n            );\n            assert_eq!(\n                matched_indices, indices,\n                \"{needle:?} match {haystack:?} {algo:?}\"\n            );\n            assert_eq!(\n                match_chars, needle_chars,\n                \"{needle:?} match {haystack:?} indices are incorrect {matched_indices:?} {algo:?}\"\n            );\n        }\n    }\n}\n\nfn assert_not_matches_with(\n    normalize: bool,\n    case_sensitive: bool,\n    algorithm: &[Algorithm],\n    cases: &[(&str, &str)],\n) {\n    let config = Config {\n        normalize,\n        ignore_case: !case_sensitive,\n        ..Config::DEFAULT\n    };\n    let mut matcher = Matcher::new(config);\n    let mut needle_buf = Vec::new();\n    let mut haystack_buf = Vec::new();\n    for &(haystack, needle) in cases {\n        let needle = if !case_sensitive {\n            needle.to_lowercase()\n        } else {\n            needle.to_owned()\n        };\n        let needle = Utf32Str::new(&needle, &mut needle_buf);\n        let haystack = Utf32Str::new(haystack, &mut haystack_buf);\n\n        for algo in algorithm {\n            let res = match algo {\n                FuzzyOptimal => matcher.fuzzy_match(haystack, needle),\n                FuzzyGreedy => matcher.fuzzy_match_greedy(haystack, needle),\n                Substring => matcher.substring_match(haystack, needle),\n                Prefix => matcher.prefix_match(haystack, needle),\n                Postfix => matcher.postfix_match(haystack, needle),\n                Exact => matcher.exact_match(haystack, needle),\n            };\n            assert_eq!(\n                res, None,\n                \"{needle:?} should not match {haystack:?} {algo:?}\"\n            );\n        }\n    }\n}\n\npub fn assert_not_matches(normalize: bool, case_sensitive: bool, cases: &[(&str, &str)]) {\n    assert_not_matches_with(\n        normalize,\n        case_sensitive,\n        &[FuzzyOptimal, FuzzyGreedy, Substring, Prefix, Postfix, Exact],\n        cases,\n    )\n}\n\nconst BONUS_BOUNDARY_WHITE: u16 = Config::DEFAULT.bonus_boundary_white;\nconst BONUS_BOUNDARY_DELIMITER: u16 = Config::DEFAULT.bonus_boundary_delimiter;\n\n#[test]\nfn test_fuzzy() {\n    assert_matches(\n        &[FuzzyGreedy, FuzzyOptimal],\n        false,\n        false,\n        false,\n        false,\n        &[\n            (\n                \"fooBarbaz1\",\n                \"obr\",\n                &[2, 3, 5],\n                BONUS_CAMEL123 - PENALTY_GAP_START,\n            ),\n            (\n                \"/usr/share/doc/at/ChangeLog\",\n                \"changelog\",\n                &[18, 19, 20, 21, 22, 23, 24, 25, 26],\n                (BONUS_FIRST_CHAR_MULTIPLIER + 8) * BONUS_BOUNDARY_DELIMITER,\n            ),\n            (\n                \"fooBarbaz1\",\n                \"br\",\n                &[3, 5],\n                BONUS_CAMEL123 * BONUS_FIRST_CHAR_MULTIPLIER - PENALTY_GAP_START,\n            ),\n            (\n                \"foo bar baz\",\n                \"fbb\",\n                &[0, 4, 8],\n                BONUS_BOUNDARY_WHITE * BONUS_FIRST_CHAR_MULTIPLIER + BONUS_BOUNDARY_WHITE * 2\n                    - 2 * PENALTY_GAP_START\n                    - 4 * PENALTY_GAP_EXTENSION,\n            ),\n            (\n                \"/AutomatorDocument.icns\",\n                \"rdoc\",\n                &[9, 10, 11, 12],\n                BONUS_CAMEL123 + 2 * BONUS_CONSECUTIVE,\n            ),\n            (\n                \"/man1/zshcompctl.1\",\n                \"zshc\",\n                &[6, 7, 8, 9],\n                BONUS_BOUNDARY_DELIMITER * (BONUS_FIRST_CHAR_MULTIPLIER + 3),\n            ),\n            (\n                \"/.oh-my-zsh/cache\",\n                \"zshc\",\n                &[8, 9, 10, 12],\n                BONUS_BOUNDARY * (BONUS_FIRST_CHAR_MULTIPLIER + 2) - PENALTY_GAP_START\n                    + BONUS_BOUNDARY_DELIMITER,\n            ),\n            (\n                \"ab0123 456\",\n                \"12356\",\n                &[3, 4, 5, 8, 9],\n                BONUS_CONSECUTIVE * 3 - PENALTY_GAP_START - PENALTY_GAP_EXTENSION,\n            ),\n            (\n                \"abc123 456\",\n                \"12356\",\n                &[3, 4, 5, 8, 9],\n                BONUS_CAMEL123 * (BONUS_FIRST_CHAR_MULTIPLIER + 2)\n                    - PENALTY_GAP_START\n                    - PENALTY_GAP_EXTENSION\n                    + BONUS_CONSECUTIVE,\n            ),\n            (\n                \"foo/bar/baz\",\n                \"fbb\",\n                &[0, 4, 8],\n                BONUS_BOUNDARY_WHITE * BONUS_FIRST_CHAR_MULTIPLIER + BONUS_BOUNDARY_DELIMITER * 2\n                    - 2 * PENALTY_GAP_START\n                    - 4 * PENALTY_GAP_EXTENSION,\n            ),\n            (\n                \"fooBarBaz\",\n                \"fbb\",\n                &[0, 3, 6],\n                BONUS_BOUNDARY_WHITE * BONUS_FIRST_CHAR_MULTIPLIER + BONUS_CAMEL123 * 2\n                    - 2 * PENALTY_GAP_START\n                    - 2 * PENALTY_GAP_EXTENSION,\n            ),\n            (\n                \"foo barbaz\",\n                \"fbb\",\n                &[0, 4, 7],\n                BONUS_BOUNDARY_WHITE * BONUS_FIRST_CHAR_MULTIPLIER + BONUS_BOUNDARY_WHITE\n                    - PENALTY_GAP_START * 2\n                    - PENALTY_GAP_EXTENSION * 3,\n            ),\n            (\n                \"fooBar Baz\",\n                \"foob\",\n                &[0, 1, 2, 3],\n                BONUS_BOUNDARY_WHITE * (BONUS_FIRST_CHAR_MULTIPLIER + 3),\n            ),\n            (\n                \"xFoo-Bar Baz\",\n                \"foo-b\",\n                &[1, 2, 3, 4, 5],\n                BONUS_CAMEL123 * (BONUS_FIRST_CHAR_MULTIPLIER + 2) + 2 * BONUS_NON_WORD,\n            ),\n        ],\n    );\n}\n\n#[test]\nfn empty_needle() {\n    assert_matches(\n        &[Substring, Prefix, Postfix, FuzzyGreedy, FuzzyOptimal, Exact],\n        false,\n        false,\n        false,\n        false,\n        &[(\"foo bar baz\", \"\", &[], 0)],\n    );\n}\n\n#[test]\nfn test_substring() {\n    assert_matches(\n        &[Substring, Prefix],\n        false,\n        false,\n        false,\n        false,\n        &[\n            (\n                \"foo bar baz\",\n                \"foo\",\n                &[0, 1, 2],\n                BONUS_BOUNDARY_WHITE * (BONUS_FIRST_CHAR_MULTIPLIER + 2),\n            ),\n            (\n                \" foo bar baz\",\n                \"FOO\",\n                &[1, 2, 3],\n                BONUS_BOUNDARY_WHITE * (BONUS_FIRST_CHAR_MULTIPLIER + 2),\n            ),\n            (\n                \" foo bar baz\",\n                \" FOO\",\n                &[0, 1, 2, 3],\n                BONUS_BOUNDARY_WHITE * (BONUS_FIRST_CHAR_MULTIPLIER + 3),\n            ),\n        ],\n    );\n    assert_matches(\n        &[Substring, Postfix],\n        false,\n        false,\n        false,\n        false,\n        &[\n            (\n                \"foo bar baz\",\n                \"baz\",\n                &[8, 9, 10],\n                BONUS_BOUNDARY_WHITE * (BONUS_FIRST_CHAR_MULTIPLIER + 2),\n            ),\n            (\n                \"foo bar baz \",\n                \"baz\",\n                &[8, 9, 10],\n                BONUS_BOUNDARY_WHITE * (BONUS_FIRST_CHAR_MULTIPLIER + 2),\n            ),\n            (\n                \"foo bar baz \",\n                \"baz \",\n                &[8, 9, 10, 11],\n                BONUS_BOUNDARY_WHITE * (BONUS_FIRST_CHAR_MULTIPLIER + 3),\n            ),\n        ],\n    );\n    assert_matches(\n        &[Substring, Prefix, Postfix, Exact, FuzzyGreedy, FuzzyOptimal],\n        false,\n        false,\n        false,\n        false,\n        &[\n            (\n                \"foo\",\n                \"foo\",\n                &[0, 1, 2],\n                BONUS_BOUNDARY_WHITE * (BONUS_FIRST_CHAR_MULTIPLIER + 2),\n            ),\n            (\n                \" foo\",\n                \"foo\",\n                &[1, 2, 3],\n                BONUS_BOUNDARY_WHITE * (BONUS_FIRST_CHAR_MULTIPLIER + 2),\n            ),\n            (\n                \" foo\",\n                \" foo\",\n                &[0, 1, 2, 3],\n                BONUS_BOUNDARY_WHITE * (BONUS_FIRST_CHAR_MULTIPLIER + 3),\n            ),\n        ],\n    );\n    assert_matches(\n        &[Substring],\n        false,\n        false,\n        false,\n        false,\n        &[\n            (\n                \"fooBarbaz1\",\n                \"oba\",\n                &[2, 3, 4],\n                BONUS_CAMEL123 + BONUS_CONSECUTIVE,\n            ),\n            (\n                \"/AutomatorDocument.icns\",\n                \"rdoc\",\n                &[9, 10, 11, 12],\n                BONUS_CAMEL123 + 2 * BONUS_CONSECUTIVE,\n            ),\n            (\n                \"/man1/zshcompctl.1\",\n                \"zshc\",\n                &[6, 7, 8, 9],\n                BONUS_BOUNDARY_DELIMITER * (BONUS_FIRST_CHAR_MULTIPLIER + 3),\n            ),\n            (\n                \"/.oh-my-zsh/cache\",\n                \"zsh/c\",\n                &[8, 9, 10, 11, 12],\n                BONUS_BOUNDARY * (BONUS_FIRST_CHAR_MULTIPLIER + 2)\n                    + BONUS_NON_WORD\n                    + BONUS_BOUNDARY_DELIMITER,\n            ),\n        ],\n    );\n    assert_not_matches_with(\n        true,\n        false,\n        &[Prefix, Substring, Postfix, Exact],\n        &[(\n            \"At the Road’s End - Seeming - SOL: A Self-Banishment Ritual\",\n            \"adi\",\n        )],\n    )\n}\n\n#[test]\nfn test_substring_case_sensitive() {\n    assert_matches(\n        &[Substring, Prefix],\n        false,\n        true,\n        false,\n        false,\n        &[\n            (\n                \"Foo bar baz\",\n                \"Foo\",\n                &[0, 1, 2],\n                BONUS_BOUNDARY_WHITE * (BONUS_FIRST_CHAR_MULTIPLIER + 2),\n            ),\n            (\n                \"Fȫô bar baz\",\n                \"Fȫô\",\n                &[0, 1, 2],\n                BONUS_BOUNDARY_WHITE * (BONUS_FIRST_CHAR_MULTIPLIER + 2),\n            ),\n            (\n                \"Foo ฿ar baz\",\n                \"Foo\",\n                &[0, 1, 2],\n                BONUS_BOUNDARY_WHITE * (BONUS_FIRST_CHAR_MULTIPLIER + 2),\n            ),\n        ],\n    );\n    assert_not_matches_with(false, true, &[Substring, Prefix], &[(\"foo bar baz\", \"Foo\")]);\n}\n\n#[test]\nfn test_fuzzy_case_sensitive() {\n    assert_matches(\n        &[FuzzyGreedy, FuzzyOptimal],\n        false,\n        true,\n        false,\n        false,\n        &[\n            (\n                \"fooBarbaz1\",\n                \"oBr\",\n                &[2, 3, 5],\n                BONUS_CAMEL123 - PENALTY_GAP_START,\n            ),\n            (\n                \"Foo/Bar/Baz\",\n                \"FBB\",\n                &[0, 4, 8],\n                BONUS_BOUNDARY_WHITE * BONUS_FIRST_CHAR_MULTIPLIER + BONUS_BOUNDARY_DELIMITER * 2\n                    - 2 * PENALTY_GAP_START\n                    - 4 * PENALTY_GAP_EXTENSION,\n            ),\n            (\n                \"FooBarBaz\",\n                \"FBB\",\n                &[0, 3, 6],\n                BONUS_BOUNDARY_WHITE * BONUS_FIRST_CHAR_MULTIPLIER + BONUS_CAMEL123 * 2\n                    - 2 * PENALTY_GAP_START\n                    - 2 * PENALTY_GAP_EXTENSION,\n            ),\n            (\n                \"FooBar Baz\",\n                \"FooB\",\n                &[0, 1, 2, 3],\n                BONUS_BOUNDARY_WHITE * (BONUS_FIRST_CHAR_MULTIPLIER + 3),\n            ),\n            (\"foo-bar\", \"o-ba\", &[2, 3, 4, 5], BONUS_NON_WORD * 3),\n        ],\n    );\n}\n\n#[test]\nfn test_normalize() {\n    assert_matches(\n        &[FuzzyGreedy, FuzzyOptimal],\n        true,\n        false,\n        false,\n        false,\n        &[\n            (\n                \"Só Danço Samba\",\n                \"So\",\n                &[0, 1],\n                BONUS_BOUNDARY_WHITE * (BONUS_FIRST_CHAR_MULTIPLIER + 1),\n            ),\n            (\n                \"Só Danço Samba\",\n                \"sodc\",\n                &[0, 1, 3, 6],\n                BONUS_BOUNDARY_WHITE * (BONUS_FIRST_CHAR_MULTIPLIER + 1) - PENALTY_GAP_START\n                    + BONUS_BOUNDARY_WHITE\n                    - PENALTY_GAP_START\n                    - PENALTY_GAP_EXTENSION,\n            ),\n            (\n                \"Danço\",\n                \"danco\",\n                &[0, 1, 2, 3, 4],\n                BONUS_BOUNDARY_WHITE * (BONUS_FIRST_CHAR_MULTIPLIER + 4),\n            ),\n            (\n                \"DanÇo\",\n                \"danco\",\n                &[0, 1, 2, 3, 4],\n                BONUS_BOUNDARY_WHITE * (BONUS_FIRST_CHAR_MULTIPLIER + 4),\n            ),\n            (\n                \"xÇando\",\n                \"cando\",\n                &[1, 2, 3, 4, 5],\n                BONUS_CAMEL123 * (BONUS_FIRST_CHAR_MULTIPLIER + 4),\n            ),\n            (\"ۂ(GCGɴCG\", \"n\", &[5], 0),\n        ],\n    )\n}\n\n#[test]\nfn test_unicode() {\n    assert_matches(\n        &[FuzzyGreedy, FuzzyOptimal, Substring],\n        true,\n        false,\n        false,\n        false,\n        &[\n            (\n                \"你好世界\",\n                \"你好\",\n                &[0, 1],\n                BONUS_BOUNDARY_WHITE * (BONUS_FIRST_CHAR_MULTIPLIER + 1),\n            ),\n            (\n                \" 你好世界\",\n                \"你好\",\n                &[1, 2],\n                BONUS_BOUNDARY_WHITE * (BONUS_FIRST_CHAR_MULTIPLIER + 1),\n            ),\n        ],\n    );\n    assert_matches(\n        &[FuzzyGreedy, FuzzyOptimal],\n        true,\n        false,\n        false,\n        false,\n        &[(\n            \"你好世界\",\n            \"你世\",\n            &[0, 2],\n            BONUS_BOUNDARY_WHITE * BONUS_FIRST_CHAR_MULTIPLIER - PENALTY_GAP_START,\n        )],\n    );\n    assert_not_matches(\n        false,\n        false,\n        &[(\"Flibbertigibbet / イタズラっ子たち\", \"lying\")],\n    );\n}\n\n#[test]\nfn test_long_str() {\n    assert_matches(\n        &[FuzzyGreedy, FuzzyOptimal],\n        false,\n        false,\n        false,\n        false,\n        &[(\n            &\"x\".repeat(u16::MAX as usize + 1),\n            \"xx\",\n            &[0, 1],\n            BONUS_BOUNDARY_WHITE * (BONUS_FIRST_CHAR_MULTIPLIER + 1),\n        )],\n    );\n}\n\n#[test]\nfn test_casing() {\n    assert_matches(\n        &[FuzzyGreedy, FuzzyOptimal],\n        false,\n        false,\n        false,\n        false,\n        &[\n            // these two have the same score\n            (\n                \"fooBar\",\n                \"foobar\",\n                &[0, 1, 2, 3, 4, 5],\n                BONUS_BOUNDARY_WHITE * (BONUS_FIRST_CHAR_MULTIPLIER + 5),\n            ),\n            (\n                \"foobar\",\n                \"foobar\",\n                &[0, 1, 2, 3, 4, 5],\n                BONUS_BOUNDARY_WHITE * (BONUS_FIRST_CHAR_MULTIPLIER + 5),\n            ),\n            // these two have the same score (slightly lower than the other two: 60 instead of 70)\n            (\n                \"foo-bar\",\n                \"foobar\",\n                &[0, 1, 2, 4, 5, 6],\n                BONUS_BOUNDARY_WHITE * (BONUS_FIRST_CHAR_MULTIPLIER + 2) - PENALTY_GAP_START\n                    + BONUS_BOUNDARY * 3,\n            ),\n            (\n                \"foo_bar\",\n                \"foobar\",\n                &[0, 1, 2, 4, 5, 6],\n                BONUS_BOUNDARY_WHITE * (BONUS_FIRST_CHAR_MULTIPLIER + 2) - PENALTY_GAP_START\n                    + BONUS_BOUNDARY * 3,\n            ),\n        ],\n    )\n}\n\n#[test]\nfn test_optimal() {\n    assert_matches(\n        &[FuzzyOptimal],\n        false,\n        false,\n        false,\n        false,\n        &[\n            (\n                \"axxx xx \",\n                \"xx\",\n                &[5, 6],\n                BONUS_BOUNDARY_WHITE * (BONUS_FIRST_CHAR_MULTIPLIER + 1),\n            ),\n            (\n                \"SS!H\",\n                \"S!\",\n                &[0, 2],\n                BONUS_BOUNDARY_WHITE * BONUS_FIRST_CHAR_MULTIPLIER - PENALTY_GAP_START\n                    + BONUS_NON_WORD,\n            ),\n            // this case is a cool example of why our algorithm is more than fzf\n            // we handle this correctly detect that it's better to match\n            // the second f instead of the third yielding a higher score\n            // (despite using the same scoring function!)\n            (\n                \"xf.foo\",\n                \"xfoo\",\n                &[0, 3, 4, 5],\n                BONUS_BOUNDARY_WHITE * BONUS_FIRST_CHAR_MULTIPLIER\n                    - PENALTY_GAP_START\n                    - PENALTY_GAP_EXTENSION\n                    + BONUS_BOUNDARY * 3,\n            ),\n            (\n                \"xf fo\",\n                \"xfo\",\n                &[0, 3, 4],\n                BONUS_BOUNDARY_WHITE * (BONUS_FIRST_CHAR_MULTIPLIER + 2)\n                    - PENALTY_GAP_START\n                    - PENALTY_GAP_EXTENSION,\n            ),\n        ],\n    );\n}\n\n#[test]\nfn test_reject() {\n    assert_not_matches(\n        true,\n        false,\n        &[\n            (\"你好界\", \"abc\"),\n            (\"你好界\", \"a\"),\n            (\"你好世界\", \"富\"),\n            (\"Só Danço Samba\", \"sox\"),\n            (\"fooBarbaz\", \"fooBarbazz\"),\n            (\"fooBarbaz\", \"c\"),\n        ],\n    );\n    assert_not_matches(\n        true,\n        true,\n        &[\n            (\"你好界\", \"abc\"),\n            (\"abc\", \"你\"),\n            (\"abc\", \"A\"),\n            (\"abc\", \"d\"),\n            (\"你好世界\", \"富\"),\n            (\"Só Danço Samba\", \"sox\"),\n            (\"fooBarbaz\", \"oBZ\"),\n            (\"Foo Bar Baz\", \"fbb\"),\n            (\"fooBarbaz\", \"fooBarbazz\"),\n        ],\n    );\n    assert_not_matches(\n        false,\n        true,\n        &[\n            (\"Só Danço Samba\", \"sod\"),\n            (\"Só Danço Samba\", \"soc\"),\n            (\"Só Danç\", \"So\"),\n        ],\n    );\n    assert_not_matches(false, false, &[(\"ۂۂfoۂۂ\", \"foo\")]);\n}\n\n#[test]\nfn test_prefer_prefix() {\n    assert_matches(\n        &[FuzzyOptimal, FuzzyGreedy],\n        false,\n        false,\n        false,\n        true,\n        &[\n            (\n                \"Moby Dick\",\n                \"md\",\n                &[0, 5],\n                BONUS_BOUNDARY_WHITE * (BONUS_FIRST_CHAR_MULTIPLIER + 1)  + MAX_PREFIX_BONUS\n                    - PENALTY_GAP_START\n                    - 3 * PENALTY_GAP_EXTENSION,\n            ),\n            (\n                \"Though I cannot tell why it was exactly that those stage managers, the Fates, put me down for this shabby part of a whaling voyage\",\n                \"md\",\n                &[82, 85],\n                BONUS_BOUNDARY_WHITE * (BONUS_FIRST_CHAR_MULTIPLIER + 1)\n                    - PENALTY_GAP_START\n                    - PENALTY_GAP_EXTENSION,\n            ),\n        ],\n    );\n}\n\n#[test]\nfn test_single_char_needle() {\n    assert_matches(\n        &[FuzzyOptimal],\n        false,\n        false,\n        false,\n        false,\n        &[(\n            \"foO\",\n            \"o\",\n            &[2],\n            BONUS_FIRST_CHAR_MULTIPLIER * BONUS_CAMEL123,\n        )],\n    );\n    assert_matches(\n        &[FuzzyOptimal],\n        false,\n        false,\n        false,\n        false,\n        &[(\n            \"föÖ\",\n            \"ö\",\n            &[2],\n            BONUS_FIRST_CHAR_MULTIPLIER * BONUS_CAMEL123,\n        )],\n    );\n}\n\n#[test]\nfn umlaut() {\n    let paths = [\"be\", \"bë\"];\n    let mut matcher = Matcher::new(Config::DEFAULT);\n    let matches = Pattern::parse(\"ë\", CaseMatching::Ignore, Normalization::Smart)\n        .match_list(paths, &mut matcher);\n    assert_eq!(matches.len(), 1);\n    let matches = Pattern::parse(\"e\", CaseMatching::Ignore, Normalization::Never)\n        .match_list(paths, &mut matcher);\n    assert_eq!(matches.len(), 1);\n    let matches = Pattern::parse(\"e\", CaseMatching::Ignore, Normalization::Smart)\n        .match_list(paths, &mut matcher);\n    assert_eq!(matches.len(), 2);\n}\n"
  },
  {
    "path": "crates/atuin-nucleo/matcher/src/utf32_str/tests.rs",
    "content": "use crate::{Utf32Str, Utf32String};\n\n#[test]\nfn test_utf32str_ascii() {\n    /// Helper function for testing\n    fn expect_ascii(src: &str, is_ascii: bool) {\n        let mut buffer = Vec::new();\n        assert!(Utf32Str::new(src, &mut buffer).is_ascii() == is_ascii);\n        assert!(Utf32String::from(src).slice(..).is_ascii() == is_ascii);\n        assert!(Utf32String::from(src.to_owned()).slice(..).is_ascii() == is_ascii);\n    }\n\n    // ascii\n    expect_ascii(\"\", true);\n    expect_ascii(\"a\", true);\n    expect_ascii(\"a\\nb\", true);\n    expect_ascii(\"\\n\\r\", true);\n\n    // not ascii\n    expect_ascii(\"aü\", false);\n    expect_ascii(\"au\\u{0308}\", false);\n\n    // windows-style newline\n    expect_ascii(\"a\\r\\nb\", false);\n    expect_ascii(\"ü\\r\\n\", false);\n    expect_ascii(\"\\r\\n\", false);\n}\n\n#[test]\nfn test_grapheme_truncation() {\n    // ascii is preserved\n    let s = Utf32String::from(\"ab\");\n    assert_eq!(s.slice(..).get(0), 'a');\n    assert_eq!(s.slice(..).get(1), 'b');\n\n    // windows-style newline is truncated to '\\n'\n    let s = Utf32String::from(\"\\r\\n\");\n    assert_eq!(s.slice(..).get(0), '\\n');\n\n    // normal graphemes are truncated to the first character\n    let s = Utf32String::from(\"u\\u{0308}\\r\\n\");\n    assert_eq!(s.slice(..).get(0), 'u');\n    assert_eq!(s.slice(..).get(1), '\\n');\n}\n"
  },
  {
    "path": "crates/atuin-nucleo/matcher/src/utf32_str.rs",
    "content": "#[cfg(test)]\nmod tests;\n\nuse std::borrow::Cow;\nuse std::ops::{Bound, RangeBounds};\nuse std::{fmt, slice};\n\nuse memchr::memmem;\n\nuse crate::chars;\n\n/// Check if a given string can be represented internally as the `Ascii` variant in a\n/// [`Utf32String`] or a [`Utf32Str`].\n///\n/// This returns true if the string is ASCII and does not contain a windows-style newline\n/// `'\\r'`.\n/// The additional carriage return check is required since even for strings consisting only\n/// of ASCII, the windows-style newline `\\r\\n` is treated as a single grapheme.\n#[inline]\nfn has_ascii_graphemes(string: &str) -> bool {\n    string.is_ascii() && memmem::find(string.as_bytes(), b\"\\r\\n\").is_none()\n}\n\n/// A UTF-32 encoded (char array) string that is used as an input to (fuzzy) matching.\n///\n/// This is mostly intended as an internal string type, but some methods are exposed for\n/// convenience. We make the following API guarantees for `Utf32Str(ing)`s produced from a string\n/// using one of its `From<T>` constructors for string types `T` or from the\n/// [`Utf32Str::new`] method.\n///\n/// 1. The `Ascii` variant contains a byte buffer which is guaranteed to be a valid string\n///    slice.\n/// 2. It is guaranteed that the string slice internal to the `Ascii` variant is identical\n///    to the original string.\n/// 3. The length of a `Utf32Str(ing)` is exactly the number of graphemes in the original string.\n///\n/// Since `Utf32Str(ing)`s variants may be constructed directly, you **must not** make these\n/// assumptions when handling `Utf32Str(ing)`s of unknown origin.\n///\n/// ## Caveats\n/// Despite the name, this type is quite far from being a true string type. Here are some\n/// examples demonstrating this.\n///\n/// ### String conversions are not round-trip\n/// In the presence of a multi-codepoint grapheme (e.g. `\"u\\u{0308}\"` which is `u +\n/// COMBINING_DIAERESIS`), the trailing codepoints are truncated.\n/// ```\n/// # use atuin_nucleo_matcher::Utf32String;\n/// assert_eq!(Utf32String::from(\"u\\u{0308}\").to_string(), \"u\");\n/// ```\n///\n/// ### Indexing is done by grapheme\n/// Indexing into a string is done by grapheme rather than by codepoint.\n/// ```\n/// # use atuin_nucleo_matcher::Utf32String;\n/// assert!(Utf32String::from(\"au\\u{0308}\").len() == 2);\n/// ```\n///\n/// ### A `Unicode` variant may be produced by all-ASCII characters.\n/// Since the windows-style newline `\\r\\n` is ASCII only but considered to be a single grapheme,\n/// strings containing `\\r\\n` will still result in a `Unicode` variant.\n/// ```\n/// # use atuin_nucleo_matcher::Utf32String;\n/// let s = Utf32String::from(\"\\r\\n\");\n/// assert!(!s.slice(..).is_ascii());\n/// assert!(s.len() == 1);\n/// assert!(s.slice(..).get(0) == '\\n');\n/// ```\n///\n/// ## Design rationale\n/// Usually Rust's UTF-8 encoded strings are great. However, since fuzzy matching\n/// operates on codepoints (ideally, it should operate on graphemes but that's too\n/// much hassle to deal with), we want to quickly iterate over codepoints (up to 5\n/// times) during matching.\n///\n/// Doing codepoint segmentation on the fly not only blows through the cache\n/// (lookup tables and I-cache) but also has nontrivial runtime compared to the\n/// matching itself. Furthermore there are many extra optimizations available\n/// for ASCII only text, but checking each match has too much overhead.\n///\n/// Of course, this comes at extra memory cost as we usually still need the UTF-8\n/// encoded variant for rendering. In the (dominant) case of ASCII-only text\n/// we don't require a copy. Furthermore fuzzy matching usually is applied while\n/// the user is typing on the fly so the same item is potentially matched many\n/// times (making the the up-front cost more worth it). That means that its\n/// basically always worth it to pre-segment the string.\n///\n/// For usecases that only match (a lot of) strings once its possible to keep\n/// char buffer around that is filled with the presegmented chars.\n///\n/// Another advantage of this approach is that the matcher will naturally\n/// produce grapheme indices (instead of utf8 offsets) anyway. With a\n/// codepoint basic representation like this the indices can be used\n/// directly\n#[derive(PartialEq, Eq, PartialOrd, Ord, Clone, Copy, Hash)]\npub enum Utf32Str<'a> {\n    /// A string represented as ASCII encoded bytes.\n    /// Correctness invariant: must only contain valid ASCII (`<= 127`)\n    Ascii(&'a [u8]),\n    /// A string represented as an array of unicode codepoints (basically UTF-32).\n    Unicode(&'a [char]),\n}\n\nimpl<'a> Utf32Str<'a> {\n    /// Convenience method to construct a `Utf32Str` from a normal UTF-8 str\n    pub fn new(str: &'a str, buf: &'a mut Vec<char>) -> Self {\n        if has_ascii_graphemes(str) {\n            Utf32Str::Ascii(str.as_bytes())\n        } else {\n            buf.clear();\n            buf.extend(crate::chars::graphemes(str));\n            Utf32Str::Unicode(buf)\n        }\n    }\n\n    /// Returns the number of characters in this string.\n    #[inline]\n    pub fn len(self) -> usize {\n        match self {\n            Utf32Str::Unicode(codepoints) => codepoints.len(),\n            Utf32Str::Ascii(ascii_bytes) => ascii_bytes.len(),\n        }\n    }\n\n    /// Returns whether this string is empty.\n    #[inline]\n    pub fn is_empty(self) -> bool {\n        match self {\n            Utf32Str::Unicode(codepoints) => codepoints.is_empty(),\n            Utf32Str::Ascii(ascii_bytes) => ascii_bytes.is_empty(),\n        }\n    }\n\n    /// Creates a slice with a string that contains the characters in\n    /// the specified **character range**.\n    #[inline]\n    pub fn slice(self, range: impl RangeBounds<usize>) -> Utf32Str<'a> {\n        let start = match range.start_bound() {\n            Bound::Included(&start) => start,\n            Bound::Excluded(&start) => start + 1,\n            Bound::Unbounded => 0,\n        };\n        let end = match range.end_bound() {\n            Bound::Included(&end) => end + 1,\n            Bound::Excluded(&end) => end,\n            Bound::Unbounded => self.len(),\n        };\n        match self {\n            Utf32Str::Ascii(bytes) => Utf32Str::Ascii(&bytes[start..end]),\n            Utf32Str::Unicode(codepoints) => Utf32Str::Unicode(&codepoints[start..end]),\n        }\n    }\n\n    /// Returns the number of leading whitespaces in this string\n    #[inline]\n    pub(crate) fn leading_white_space(self) -> usize {\n        match self {\n            Utf32Str::Ascii(bytes) => bytes\n                .iter()\n                .position(|b| !b.is_ascii_whitespace())\n                .unwrap_or(0),\n            Utf32Str::Unicode(codepoints) => codepoints\n                .iter()\n                .position(|c| !c.is_whitespace())\n                .unwrap_or(0),\n        }\n    }\n\n    /// Returns the number of trailing whitespaces in this string\n    #[inline]\n    pub(crate) fn trailing_white_space(self) -> usize {\n        match self {\n            Utf32Str::Ascii(bytes) => bytes\n                .iter()\n                .rev()\n                .position(|b| !b.is_ascii_whitespace())\n                .unwrap_or(0),\n            Utf32Str::Unicode(codepoints) => codepoints\n                .iter()\n                .rev()\n                .position(|c| !c.is_whitespace())\n                .unwrap_or(0),\n        }\n    }\n\n    /// Same as `slice` but accepts a u32 range for convenience since\n    /// those are the indices returned by the matcher.\n    #[inline]\n    pub fn slice_u32(self, range: impl RangeBounds<u32>) -> Utf32Str<'a> {\n        let start = match range.start_bound() {\n            Bound::Included(&start) => start as usize,\n            Bound::Excluded(&start) => start as usize + 1,\n            Bound::Unbounded => 0,\n        };\n        let end = match range.end_bound() {\n            Bound::Included(&end) => end as usize + 1,\n            Bound::Excluded(&end) => end as usize,\n            Bound::Unbounded => self.len(),\n        };\n        match self {\n            Utf32Str::Ascii(bytes) => Utf32Str::Ascii(&bytes[start..end]),\n            Utf32Str::Unicode(codepoints) => Utf32Str::Unicode(&codepoints[start..end]),\n        }\n    }\n\n    /// Returns whether this string only contains graphemes which are single ASCII chars.\n    ///\n    /// This is almost equivalent to the string being ASCII, except with the additional requirement\n    /// that the string cannot contain a windows-style newline `\\r\\n` which is treated as a single\n    /// grapheme.\n    pub fn is_ascii(self) -> bool {\n        matches!(self, Utf32Str::Ascii(_))\n    }\n\n    /// Returns the `n`th character in this string, zero-indexed\n    pub fn get(self, n: u32) -> char {\n        match self {\n            Utf32Str::Ascii(bytes) => bytes[n as usize] as char,\n            Utf32Str::Unicode(codepoints) => codepoints[n as usize],\n        }\n    }\n\n    /// Returns the last character in this string.\n    ///\n    /// Panics if the string is empty.\n    pub(crate) fn last(self) -> char {\n        match self {\n            Utf32Str::Ascii(bytes) => bytes[bytes.len() - 1] as char,\n            Utf32Str::Unicode(codepoints) => codepoints[codepoints.len() - 1],\n        }\n    }\n\n    /// Returns the first character in this string.\n    ///\n    /// Panics if the string is empty.\n    pub(crate) fn first(self) -> char {\n        match self {\n            Utf32Str::Ascii(bytes) => bytes[0] as char,\n            Utf32Str::Unicode(codepoints) => codepoints[0],\n        }\n    }\n\n    /// Returns an iterator over the characters in this string\n    pub fn chars(self) -> Chars<'a> {\n        match self {\n            Utf32Str::Ascii(bytes) => Chars::Ascii(bytes.iter()),\n            Utf32Str::Unicode(codepoints) => Chars::Unicode(codepoints.iter()),\n        }\n    }\n}\n\nimpl fmt::Debug for Utf32Str<'_> {\n    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {\n        write!(f, \"\\\"\")?;\n        for c in self.chars() {\n            for c in c.escape_debug() {\n                write!(f, \"{c}\")?\n            }\n        }\n        write!(f, \"\\\"\")\n    }\n}\n\nimpl fmt::Display for Utf32Str<'_> {\n    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {\n        for c in self.chars() {\n            write!(f, \"{c}\")?\n        }\n        Ok(())\n    }\n}\n\npub enum Chars<'a> {\n    Ascii(slice::Iter<'a, u8>),\n    Unicode(slice::Iter<'a, char>),\n}\n\nimpl Iterator for Chars<'_> {\n    type Item = char;\n\n    fn next(&mut self) -> Option<Self::Item> {\n        match self {\n            Chars::Ascii(iter) => iter.next().map(|&c| c as char),\n            Chars::Unicode(iter) => iter.next().copied(),\n        }\n    }\n}\n\nimpl DoubleEndedIterator for Chars<'_> {\n    fn next_back(&mut self) -> Option<Self::Item> {\n        match self {\n            Chars::Ascii(iter) => iter.next_back().map(|&c| c as char),\n            Chars::Unicode(iter) => iter.next_back().copied(),\n        }\n    }\n}\n\n#[derive(PartialEq, Eq, PartialOrd, Ord, Clone, Hash)]\n/// An owned version of [`Utf32Str`].\n///\n/// See the API documentation for [`Utf32Str`] for more detail.\npub enum Utf32String {\n    /// A string represented as ASCII encoded bytes.\n    /// Correctness invariant: must only contain valid ASCII (<=127)\n    Ascii(Box<str>),\n    /// A string represented as an array of unicode codepoints (basically UTF-32).\n    Unicode(Box<[char]>),\n}\n\nimpl Default for Utf32String {\n    fn default() -> Self {\n        Self::Ascii(String::new().into_boxed_str())\n    }\n}\n\nimpl Utf32String {\n    /// Returns the number of characters in this string.\n    #[inline]\n    pub fn len(&self) -> usize {\n        match self {\n            Utf32String::Unicode(codepoints) => codepoints.len(),\n            Utf32String::Ascii(ascii_bytes) => ascii_bytes.len(),\n        }\n    }\n\n    /// Returns whether this string is empty.\n    #[inline]\n    pub fn is_empty(&self) -> bool {\n        match self {\n            Utf32String::Unicode(codepoints) => codepoints.is_empty(),\n            Utf32String::Ascii(ascii_bytes) => ascii_bytes.is_empty(),\n        }\n    }\n\n    /// Creates a slice with a string that contains the characters in\n    /// the specified **character range**.\n    #[inline]\n    pub fn slice(&self, range: impl RangeBounds<usize>) -> Utf32Str {\n        let start = match range.start_bound() {\n            Bound::Included(&start) => start,\n            Bound::Excluded(&start) => start + 1,\n            Bound::Unbounded => 0,\n        };\n        let end = match range.end_bound() {\n            Bound::Included(&end) => end + 1,\n            Bound::Excluded(&end) => end,\n            Bound::Unbounded => self.len(),\n        };\n        match self {\n            Utf32String::Ascii(bytes) => Utf32Str::Ascii(&bytes.as_bytes()[start..end]),\n            Utf32String::Unicode(codepoints) => Utf32Str::Unicode(&codepoints[start..end]),\n        }\n    }\n\n    /// Same as `slice` but accepts a u32 range for convenience since\n    /// those are the indices returned by the matcher.\n    #[inline]\n    pub fn slice_u32(&self, range: impl RangeBounds<u32>) -> Utf32Str {\n        let start = match range.start_bound() {\n            Bound::Included(&start) => start,\n            Bound::Excluded(&start) => start + 1,\n            Bound::Unbounded => 0,\n        };\n        let end = match range.end_bound() {\n            Bound::Included(&end) => end + 1,\n            Bound::Excluded(&end) => end,\n            Bound::Unbounded => self.len() as u32,\n        };\n        match self {\n            Utf32String::Ascii(bytes) => {\n                Utf32Str::Ascii(&bytes.as_bytes()[start as usize..end as usize])\n            }\n            Utf32String::Unicode(codepoints) => {\n                Utf32Str::Unicode(&codepoints[start as usize..end as usize])\n            }\n        }\n    }\n}\n\nimpl From<&str> for Utf32String {\n    #[inline]\n    fn from(value: &str) -> Self {\n        if has_ascii_graphemes(value) {\n            Self::Ascii(value.to_owned().into_boxed_str())\n        } else {\n            Self::Unicode(chars::graphemes(value).collect())\n        }\n    }\n}\n\nimpl From<Box<str>> for Utf32String {\n    fn from(value: Box<str>) -> Self {\n        if has_ascii_graphemes(&value) {\n            Self::Ascii(value)\n        } else {\n            Self::Unicode(chars::graphemes(&value).collect())\n        }\n    }\n}\n\nimpl From<String> for Utf32String {\n    #[inline]\n    fn from(value: String) -> Self {\n        value.into_boxed_str().into()\n    }\n}\n\nimpl<'a> From<Cow<'a, str>> for Utf32String {\n    #[inline]\n    fn from(value: Cow<'a, str>) -> Self {\n        match value {\n            Cow::Borrowed(value) => value.into(),\n            Cow::Owned(value) => value.into(),\n        }\n    }\n}\n\nimpl fmt::Debug for Utf32String {\n    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {\n        write!(f, \"{:?}\", self.slice(..))\n    }\n}\n\nimpl fmt::Display for Utf32String {\n    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {\n        write!(f, \"{}\", self.slice(..))\n    }\n}\n"
  },
  {
    "path": "crates/atuin-nucleo/src/boxcar.rs",
    "content": "//! Adapted from the `boxcar` crate at <https://github.com/ibraheemdev/boxcar/blob/master/src/raw.rs>\n//! under MIT licenes:\n//!\n//! Copyright (c) 2022 Ibraheem Ahmed\n//!\n//! Permission is hereby granted, free of charge, to any person obtaining a copy\n//! of this software and associated documentation files (the \"Software\"), to deal\n//! in the Software without restriction, including without limitation the rights\n//! to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\n//! copies of the Software, and to permit persons to whom the Software is\n//! furnished to do so, subject to the following conditions:\n//!\n//! The above copyright notice and this permission notice shall be included in all\n//! copies or substantial portions of the Software.\n//!\n//! THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\n//! IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\n//! FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\n//! AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\n//! LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\n//! OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\n//! SOFTWARE.\n\nuse std::alloc::Layout;\nuse std::cell::UnsafeCell;\nuse std::fmt::Debug;\nuse std::mem::MaybeUninit;\nuse std::sync::atomic::{AtomicBool, AtomicPtr, AtomicU64, Ordering};\nuse std::{ptr, slice};\n\nuse crate::{Item, Utf32String};\n\nconst BUCKETS: u32 = u32::BITS - SKIP_BUCKET;\nconst MAX_ENTRIES: u32 = u32::MAX - SKIP;\n\n/// A lock-free, append-only vector.\npub(crate) struct Vec<T> {\n    /// a counter used to retrieve a unique index to push to.\n    ///\n    /// this value may be more than the true length as it will\n    /// be incremented before values are actually stored.\n    inflight: AtomicU64,\n    /// buckets of length 32, 64 .. 2^31\n    buckets: [Bucket<T>; BUCKETS as usize],\n    /// the number of matcher columns in this vector, its absolutely critical that\n    /// this remains constant and after initilaziaton (safety invariant) since\n    /// it is used to calculate the Entry layout\n    columns: u32,\n}\n\nimpl<T> Vec<T> {\n    /// Constructs a new, empty `Vec<T>` with the specified capacity and matcher columns.\n    pub fn with_capacity(capacity: u32, columns: u32) -> Vec<T> {\n        assert_ne!(columns, 0, \"there must be at least one matcher column\");\n        let init = match capacity {\n            0 => 0,\n            // initialize enough buckets for `capacity` elements\n            n => Location::of(n).bucket,\n        };\n\n        let mut buckets = [ptr::null_mut(); BUCKETS as usize];\n\n        for (i, bucket) in buckets[..=init as usize].iter_mut().enumerate() {\n            let len = Location::bucket_len(i as u32);\n            *bucket = unsafe { Bucket::alloc(len, columns) };\n        }\n\n        Vec {\n            buckets: buckets.map(Bucket::new),\n            inflight: AtomicU64::new(0),\n            columns,\n        }\n    }\n    pub fn columns(&self) -> u32 {\n        self.columns\n    }\n\n    /// Returns the number of elements in the vector.\n    #[inline]\n    pub fn count(&self) -> u32 {\n        self.inflight\n            .load(Ordering::Acquire)\n            .min(MAX_ENTRIES as u64) as u32\n    }\n\n    // Returns a reference to the element at the given index.\n    //\n    // # Safety\n    //\n    // Entry at `index` must be initialized.\n    #[inline]\n    pub unsafe fn get_unchecked(&self, index: u32) -> Item<'_, T> {\n        let location = Location::of(index);\n\n        unsafe {\n            let entries = self\n                .buckets\n                .get_unchecked(location.bucket as usize)\n                .entries\n                .load(Ordering::Relaxed);\n            debug_assert!(!entries.is_null());\n            let entry = Bucket::<T>::get(entries, location.entry, self.columns);\n            // this looks odd but is necessary to ensure cross\n            // thread synchronization (essentially acting as a memory barrier)\n            // since the caller must only guarantee that he has observed active on any thread\n            // but the current thread might still have an old value cached (although unlikely)\n            let _ = (*entry).active.load(Ordering::Acquire);\n            Entry::read(entry, self.columns)\n        }\n    }\n\n    /// Returns a reference to the element at the given index.\n    pub fn get(&self, index: u32) -> Option<Item<'_, T>> {\n        let location = Location::of(index);\n\n        unsafe {\n            // safety: `location.bucket` is always in bounds\n            let entries = self\n                .buckets\n                .get_unchecked(location.bucket as usize)\n                .entries\n                .load(Ordering::Relaxed);\n\n            // bucket is uninitialized\n            if entries.is_null() {\n                return None;\n            }\n\n            // safety: `location.entry` is always in bounds for it's bucket\n            let entry = Bucket::<T>::get(entries, location.entry, self.columns);\n\n            // safety: the entry is active\n            (*entry)\n                .active\n                .load(Ordering::Acquire)\n                .then(|| Entry::read(entry, self.columns))\n        }\n    }\n\n    /// Appends an element to the back of the vector.\n    pub fn push(&self, value: T, fill_columns: impl FnOnce(&T, &mut [Utf32String])) -> u32 {\n        let index = self.inflight.fetch_add(1, Ordering::Release);\n        // the inflight counter is a `u64` to catch overflows of the vector'scapacity\n        let index: u32 = index.try_into().expect(\"overflowed maximum capacity\");\n        let location = Location::of(index);\n\n        // eagerly allocate the next bucket if we are close to the end of this one\n        if index == (location.bucket_len - (location.bucket_len >> 3)) {\n            if let Some(next_bucket) = self.buckets.get(location.bucket as usize + 1) {\n                Vec::get_or_alloc(next_bucket, location.bucket_len << 1, self.columns);\n            }\n        }\n\n        // safety: `location.bucket` is always in bounds\n        let bucket = unsafe { self.buckets.get_unchecked(location.bucket as usize) };\n        let mut entries = bucket.entries.load(Ordering::Acquire);\n\n        // the bucket has not been allocated yet\n        if entries.is_null() {\n            entries = Vec::get_or_alloc(bucket, location.bucket_len, self.columns);\n        }\n\n        unsafe {\n            // safety: `location.entry` is always in bounds for it's bucket\n            let entry = Bucket::get(entries, location.entry, self.columns);\n\n            // safety: we have unique access to this entry.\n            //\n            // 1. it is impossible for another thread to attempt a `push`\n            // to this location as we retrieved it from `inflight.fetch_add`\n            //\n            // 2. any thread trying to `get` this entry will see `active == false`,\n            // and will not try to access it\n            for col in Entry::matcher_cols_raw(entry, self.columns) {\n                col.get().write(MaybeUninit::new(Utf32String::default()))\n            }\n            fill_columns(&value, Entry::matcher_cols_mut(entry, self.columns));\n            (*entry).slot.get().write(MaybeUninit::new(value));\n            // let other threads know that this entry is active\n            (*entry).active.store(true, Ordering::Release);\n        }\n\n        index\n    }\n\n    /// Extends the vector by appending multiple elements at once.\n    pub fn extend<I>(&self, values: I, fill_columns: impl Fn(&T, &mut [Utf32String]))\n    where\n        I: IntoIterator<Item = T> + ExactSizeIterator,\n    {\n        let count: u32 = values\n            .len()\n            .try_into()\n            .expect(\"overflowed maximum capacity\");\n        if count == 0 {\n            assert!(\n                values.into_iter().next().is_none(),\n                \"The `values` variable reported incorrect length.\"\n            );\n            return;\n        }\n\n        // Reserve all indices at once\n        let start_index: u32 = self\n            .inflight\n            .fetch_add(u64::from(count), Ordering::Release)\n            .try_into()\n            .expect(\"overflowed maximum capacity\");\n\n        // Compute first and last locations\n        let start_location = Location::of(start_index);\n        let end_location = Location::of(start_index + count);\n\n        // Eagerly allocate the next bucket if the last entry is close to the end of its next bucket\n        let alloc_entry = end_location.alloc_next_bucket_entry();\n        if end_location.entry >= alloc_entry\n            && (start_location.bucket != end_location.bucket || start_location.entry <= alloc_entry)\n        {\n            // This might be the last bucket, hence the check\n            if let Some(next_bucket) = self.buckets.get(end_location.bucket as usize + 1) {\n                Vec::get_or_alloc(next_bucket, end_location.bucket_len << 1, self.columns);\n            }\n        }\n\n        let mut bucket = unsafe { self.buckets.get_unchecked(start_location.bucket as usize) };\n        let mut entries = bucket.entries.load(Ordering::Acquire);\n        if entries.is_null() {\n            entries = Vec::get_or_alloc(\n                bucket,\n                Location::bucket_len(start_location.bucket),\n                self.columns,\n            );\n        }\n        // Route each value to its corresponding bucket\n        let mut location;\n        let count = count as usize;\n        for (i, v) in values.into_iter().enumerate() {\n            // ExactSizeIterator is a safe trait that can have bugs/lie about it's size.\n            // Unsafe code cannot rely on the reported length being correct.\n            assert!(i < count);\n\n            location =\n                Location::of(start_index + u32::try_from(i).expect(\"overflowed maximum capacity\"));\n\n            // if we're starting to insert into a different bucket, allocate it beforehand\n            if location.entry == 0 && i != 0 {\n                // safety: `location.bucket` is always in bounds\n                bucket = unsafe { self.buckets.get_unchecked(location.bucket as usize) };\n                entries = bucket.entries.load(Ordering::Acquire);\n\n                if entries.is_null() {\n                    entries = Vec::get_or_alloc(\n                        bucket,\n                        Location::bucket_len(location.bucket),\n                        self.columns,\n                    );\n                }\n            }\n\n            unsafe {\n                let entry = Bucket::get(entries, location.entry, self.columns);\n\n                // Initialize matcher columns\n                for col in Entry::matcher_cols_raw(entry, self.columns) {\n                    col.get().write(MaybeUninit::new(Utf32String::default()));\n                }\n                fill_columns(&v, Entry::matcher_cols_mut(entry, self.columns));\n                (*entry).slot.get().write(MaybeUninit::new(v));\n                (*entry).active.store(true, Ordering::Release);\n            }\n        }\n    }\n\n    /// race to initialize a bucket\n    fn get_or_alloc(bucket: &Bucket<T>, len: u32, cols: u32) -> *mut Entry<T> {\n        let entries = unsafe { Bucket::alloc(len, cols) };\n        match bucket.entries.compare_exchange(\n            ptr::null_mut(),\n            entries,\n            Ordering::Release,\n            Ordering::Acquire,\n        ) {\n            Ok(_) => entries,\n            Err(found) => unsafe {\n                Bucket::dealloc(entries, len, cols);\n                found\n            },\n        }\n    }\n\n    /// Returns an iterator over the vector starting at `start`\n    /// the iterator is deterministically sized and will not grow\n    /// as more elements are pushed\n    pub unsafe fn snapshot(&self, start: u32) -> Iter<'_, T> {\n        let end = self\n            .inflight\n            .load(Ordering::Acquire)\n            .min(MAX_ENTRIES as u64) as u32;\n        assert!(start <= end, \"index {start} is out of bounds!\");\n        Iter {\n            location: Location::of(start),\n            vec: self,\n            idx: start,\n            end,\n        }\n    }\n\n    /// Returns an iterator over the vector starting at `start`\n    /// the iterator is deterministically sized and will not grow\n    /// as more elements are pushed\n    pub unsafe fn par_snapshot(&self, start: u32) -> ParIter<'_, T> {\n        let end = self\n            .inflight\n            .load(Ordering::Acquire)\n            .min(MAX_ENTRIES as u64) as u32;\n        assert!(start <= end, \"index {start} is out of bounds!\");\n\n        ParIter {\n            start,\n            end,\n            vec: self,\n        }\n    }\n}\n\nimpl<T> Drop for Vec<T> {\n    fn drop(&mut self) {\n        for (i, bucket) in self.buckets.iter_mut().enumerate() {\n            let entries = *bucket.entries.get_mut();\n\n            if entries.is_null() {\n                break;\n            }\n\n            let len = Location::bucket_len(i as u32);\n            // safety: in drop\n            unsafe { Bucket::dealloc(entries, len, self.columns) }\n        }\n    }\n}\ntype SnapshotItem<'v, T> = (u32, Option<Item<'v, T>>);\n\npub struct Iter<'v, T> {\n    location: Location,\n    idx: u32,\n    end: u32,\n    vec: &'v Vec<T>,\n}\nimpl<T> Iter<'_, T> {\n    pub fn end(&self) -> u32 {\n        self.end\n    }\n}\n\nimpl<'v, T> Iterator for Iter<'v, T> {\n    type Item = SnapshotItem<'v, T>;\n    fn size_hint(&self) -> (usize, Option<usize>) {\n        (\n            (self.end - self.idx) as usize,\n            Some((self.end - self.idx) as usize),\n        )\n    }\n\n    fn next(&mut self) -> Option<SnapshotItem<'v, T>> {\n        if self.end == self.idx {\n            return None;\n        }\n        debug_assert!(self.idx < self.end, \"huh {} {}\", self.idx, self.end);\n        debug_assert!(self.end as u64 <= self.vec.inflight.load(Ordering::Relaxed));\n\n        loop {\n            let entries = unsafe {\n                self.vec\n                    .buckets\n                    .get_unchecked(self.location.bucket as usize)\n                    .entries\n                    .load(Ordering::Relaxed)\n            };\n            debug_assert!(self.location.bucket < BUCKETS);\n\n            if self.location.entry < self.location.bucket_len {\n                if entries.is_null() {\n                    // we still want to yield these\n                    let index = self.idx;\n                    self.location.entry += 1;\n                    self.idx += 1;\n                    return Some((index, None));\n                }\n                // safety: bounds and null checked above\n                let entry = unsafe { Bucket::get(entries, self.location.entry, self.vec.columns) };\n                let index = self.idx;\n                self.location.entry += 1;\n                self.idx += 1;\n\n                let entry = unsafe {\n                    (*entry)\n                        .active\n                        .load(Ordering::Acquire)\n                        .then(|| Entry::read(entry, self.vec.columns))\n                };\n                return Some((index, entry));\n            }\n\n            self.location.entry = 0;\n            self.location.bucket += 1;\n\n            if self.location.bucket < BUCKETS {\n                self.location.bucket_len = Location::bucket_len(self.location.bucket);\n            }\n        }\n    }\n}\nimpl<T> ExactSizeIterator for Iter<'_, T> {}\nimpl<T> DoubleEndedIterator for Iter<'_, T> {\n    fn next_back(&mut self) -> Option<Self::Item> {\n        unimplemented!()\n    }\n}\n\npub struct ParIter<'v, T> {\n    end: u32,\n    start: u32,\n    vec: &'v Vec<T>,\n}\nimpl<T> ParIter<'_, T> {\n    pub fn end(&self) -> u32 {\n        self.end\n    }\n}\n\nimpl<'v, T: Send + Sync> rayon::iter::ParallelIterator for ParIter<'v, T> {\n    type Item = SnapshotItem<'v, T>;\n\n    fn drive_unindexed<C>(self, consumer: C) -> C::Result\n    where\n        C: rayon::iter::plumbing::UnindexedConsumer<Self::Item>,\n    {\n        rayon::iter::plumbing::bridge(self, consumer)\n    }\n\n    fn opt_len(&self) -> Option<usize> {\n        Some((self.end - self.start) as usize)\n    }\n}\n\nimpl<T: Send + Sync> rayon::iter::IndexedParallelIterator for ParIter<'_, T> {\n    fn len(&self) -> usize {\n        (self.end - self.start) as usize\n    }\n\n    fn drive<C: rayon::iter::plumbing::Consumer<Self::Item>>(self, consumer: C) -> C::Result {\n        rayon::iter::plumbing::bridge(self, consumer)\n    }\n\n    fn with_producer<CB>(self, callback: CB) -> CB::Output\n    where\n        CB: rayon::iter::plumbing::ProducerCallback<Self::Item>,\n    {\n        callback.callback(ParIterProducer {\n            start: self.start,\n            end: self.end,\n            vec: self.vec,\n        })\n    }\n}\n\nstruct ParIterProducer<'v, T: Send> {\n    start: u32,\n    end: u32,\n    vec: &'v Vec<T>,\n}\n\nimpl<'v, T: 'v + Send + Sync> rayon::iter::plumbing::Producer for ParIterProducer<'v, T> {\n    type Item = SnapshotItem<'v, T>;\n    type IntoIter = Iter<'v, T>;\n\n    fn into_iter(self) -> Self::IntoIter {\n        debug_assert!(self.start <= self.end);\n        Iter {\n            location: Location::of(self.start),\n            idx: self.start,\n            end: self.end,\n            vec: self.vec,\n        }\n    }\n\n    fn split_at(self, index: usize) -> (Self, Self) {\n        assert!(index <= (self.end - self.start) as usize);\n        let index = index as u32;\n        (\n            ParIterProducer {\n                start: self.start,\n                end: self.start + index,\n                vec: self.vec,\n            },\n            ParIterProducer {\n                start: self.start + index,\n                end: self.end,\n                vec: self.vec,\n            },\n        )\n    }\n}\n\nstruct Bucket<T> {\n    entries: AtomicPtr<Entry<T>>,\n}\n\nimpl<T> Bucket<T> {\n    fn layout(len: u32, layout: Layout) -> Layout {\n        Layout::from_size_align(layout.size() * len as usize, layout.align())\n            .expect(\"exceeded maximum allocation size\")\n    }\n\n    unsafe fn alloc(len: u32, cols: u32) -> *mut Entry<T> {\n        let layout = Entry::<T>::layout(cols);\n        let arr_layout = Self::layout(len, layout);\n        let entries = std::alloc::alloc(arr_layout);\n        if entries.is_null() {\n            std::alloc::handle_alloc_error(arr_layout)\n        }\n\n        for i in 0..len {\n            let active = entries.add(i as usize * layout.size()) as *mut AtomicBool;\n            active.write(AtomicBool::new(false))\n        }\n        entries as *mut Entry<T>\n    }\n\n    unsafe fn dealloc(entries: *mut Entry<T>, len: u32, cols: u32) {\n        let layout = Entry::<T>::layout(cols);\n        let arr_layout = Self::layout(len, layout);\n        for i in 0..len {\n            let entry = Bucket::get(entries, i, cols);\n            if *(*entry).active.get_mut() {\n                ptr::drop_in_place((*(*entry).slot.get()).as_mut_ptr());\n                for matcher_col in Entry::matcher_cols_raw(entry, cols) {\n                    ptr::drop_in_place((*matcher_col.get()).as_mut_ptr());\n                }\n            }\n        }\n        std::alloc::dealloc(entries as *mut u8, arr_layout)\n    }\n\n    unsafe fn get(entries: *mut Entry<T>, idx: u32, cols: u32) -> *mut Entry<T> {\n        let layout = Entry::<T>::layout(cols);\n        let ptr = entries as *mut u8;\n        ptr.add(layout.size() * idx as usize) as *mut Entry<T>\n    }\n\n    fn new(entries: *mut Entry<T>) -> Bucket<T> {\n        Bucket {\n            entries: AtomicPtr::new(entries),\n        }\n    }\n}\n\n#[repr(C)]\nstruct Entry<T> {\n    active: AtomicBool,\n    slot: UnsafeCell<MaybeUninit<T>>,\n    tail: [UnsafeCell<MaybeUninit<Utf32String>>; 0],\n}\n\nimpl<T> Entry<T> {\n    fn layout(cols: u32) -> Layout {\n        let head = Layout::new::<Self>();\n        let tail = Layout::array::<Utf32String>(cols as usize).expect(\"invalid memory layout\");\n        head.extend(tail)\n            .expect(\"invalid memory layout\")\n            .0\n            .pad_to_align()\n    }\n\n    unsafe fn matcher_cols_raw<'a>(\n        ptr: *mut Entry<T>,\n        cols: u32,\n    ) -> &'a [UnsafeCell<MaybeUninit<Utf32String>>] {\n        // this whole thing looks weird. The reason we do this is that\n        // we must make sure the pointer retains its provenance which may (or may not?)\n        // be lost if we used tail.as_ptr()\n        let tail = std::ptr::addr_of!((*ptr).tail) as *const u8;\n        let offset = tail.offset_from(ptr as *mut u8) as usize;\n        let ptr = (ptr as *mut u8).add(offset) as *mut _;\n        slice::from_raw_parts(ptr, cols as usize)\n    }\n\n    unsafe fn matcher_cols_mut<'a>(ptr: *mut Entry<T>, cols: u32) -> &'a mut [Utf32String] {\n        // this whole thing looks weird. The reason we do this is that\n        // we must make sure the pointer retains its provenance which may (or may not?)\n        // be lost if we used tail.as_ptr()\n        let tail = std::ptr::addr_of!((*ptr).tail) as *const u8;\n        let offset = tail.offset_from(ptr as *mut u8) as usize;\n        let ptr = (ptr as *mut u8).add(offset) as *mut _;\n        slice::from_raw_parts_mut(ptr, cols as usize)\n    }\n    // # Safety\n    //\n    // Value must be initialized.\n    unsafe fn read<'a>(ptr: *mut Entry<T>, cols: u32) -> Item<'a, T> {\n        // this whole thing looks weird. The reason we do this is that\n        // we must make sure the pointer retains its provenance which may (or may not?)\n        // be lost if we used tail.as_ptr()\n        let data = (*(*ptr).slot.get()).assume_init_ref();\n        let tail = std::ptr::addr_of!((*ptr).tail) as *const u8;\n        let offset = tail.offset_from(ptr as *mut u8) as usize;\n        let ptr = (ptr as *mut u8).add(offset) as *mut _;\n        let matcher_columns = slice::from_raw_parts(ptr, cols as usize);\n        Item {\n            data,\n            matcher_columns,\n        }\n    }\n}\n\n#[derive(Debug)]\nstruct Location {\n    // the index of the bucket\n    bucket: u32,\n    // the length of `bucket`\n    bucket_len: u32,\n    // the index of the entry in `bucket`\n    entry: u32,\n}\n\n// skip the shorter buckets to avoid unnecessary allocations.\n// this also reduces the maximum capacity of a vector.\nconst SKIP: u32 = 32;\nconst SKIP_BUCKET: u32 = (u32::BITS - SKIP.leading_zeros()) - 1;\n\nimpl Location {\n    fn of(index: u32) -> Location {\n        let skipped = index.checked_add(SKIP).expect(\"exceeded maximum length\");\n        let bucket = u32::BITS - skipped.leading_zeros();\n        let bucket = bucket - (SKIP_BUCKET + 1);\n        let bucket_len = Location::bucket_len(bucket);\n        let entry = skipped ^ bucket_len;\n\n        Location {\n            bucket,\n            bucket_len,\n            entry,\n        }\n    }\n\n    fn bucket_len(bucket: u32) -> u32 {\n        1 << (bucket + SKIP_BUCKET)\n    }\n\n    /// The entry index at which the next bucket should be pre-allocated.\n    fn alloc_next_bucket_entry(&self) -> u32 {\n        self.bucket_len - (self.bucket_len >> 3)\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn location() {\n        assert_eq!(Location::bucket_len(0), 32);\n        for i in 0..32 {\n            let loc = Location::of(i);\n            assert_eq!(loc.bucket_len, 32);\n            assert_eq!(loc.bucket, 0);\n            assert_eq!(loc.entry, i);\n        }\n\n        assert_eq!(Location::bucket_len(1), 64);\n        for i in 33..96 {\n            let loc = Location::of(i);\n            assert_eq!(loc.bucket_len, 64);\n            assert_eq!(loc.bucket, 1);\n            assert_eq!(loc.entry, i - 32);\n        }\n\n        assert_eq!(Location::bucket_len(2), 128);\n        for i in 96..224 {\n            let loc = Location::of(i);\n            assert_eq!(loc.bucket_len, 128);\n            assert_eq!(loc.bucket, 2);\n            assert_eq!(loc.entry, i - 96);\n        }\n\n        let max = Location::of(MAX_ENTRIES);\n        assert_eq!(max.bucket, BUCKETS - 1);\n        assert_eq!(max.bucket_len, 1 << 31);\n        assert_eq!(max.entry, (1 << 31) - 1);\n    }\n\n    #[test]\n    fn extend_unique_bucket() {\n        let vec = Vec::<u32>::with_capacity(1, 1);\n        vec.extend(0..10, |_, _| {});\n        assert_eq!(vec.count(), 10);\n        for i in 0..10 {\n            assert_eq!(*vec.get(i).unwrap().data, i);\n        }\n        assert!(vec.get(10).is_none());\n    }\n\n    #[test]\n    fn extend_over_two_buckets() {\n        let vec = Vec::<u32>::with_capacity(1, 1);\n        vec.extend(0..100, |_, _| {});\n        assert_eq!(vec.count(), 100);\n        for i in 0..100 {\n            assert_eq!(*vec.get(i).unwrap().data, i);\n        }\n        assert!(vec.get(100).is_none());\n    }\n\n    #[test]\n    fn extend_over_more_than_two_buckets() {\n        let vec = Vec::<u32>::with_capacity(1, 1);\n        vec.extend(0..1000, |_, _| {});\n        assert_eq!(vec.count(), 1000);\n        for i in 0..1000 {\n            assert_eq!(*vec.get(i).unwrap().data, i);\n        }\n        assert!(vec.get(1000).is_none());\n    }\n\n    #[test]\n    /// test that ExactSizeIterator returning incorrect length is caught (0 AND more than reported)\n    fn extend_with_incorrect_reported_len_is_caught() {\n        struct IncorrectLenIter {\n            len: usize,\n            iter: std::ops::Range<u32>,\n        }\n\n        impl Iterator for IncorrectLenIter {\n            type Item = u32;\n\n            fn next(&mut self) -> Option<Self::Item> {\n                self.iter.next()\n            }\n        }\n\n        impl ExactSizeIterator for IncorrectLenIter {\n            fn len(&self) -> usize {\n                self.len\n            }\n        }\n\n        let vec = Vec::<u32>::with_capacity(1, 1);\n        let iter = IncorrectLenIter {\n            len: 10,\n            iter: (0..12),\n        };\n        // this should panic\n        assert!(std::panic::catch_unwind(|| vec.extend(iter, |_, _| {})).is_err());\n\n        let vec = Vec::<u32>::with_capacity(1, 1);\n        let iter = IncorrectLenIter {\n            len: 12,\n            iter: (0..10),\n        };\n        // this shouldn't panic and should just ignore the extra elements\n        assert!(std::panic::catch_unwind(|| vec.extend(iter, |_, _| {})).is_ok());\n        // we should reserve 12 elements but only 10 should be present\n        assert_eq!(vec.count(), 12);\n        for i in 0..10 {\n            assert_eq!(*vec.get(i).unwrap().data, i);\n        }\n        assert!(vec.get(10).is_none());\n\n        let vec = Vec::<u32>::with_capacity(1, 1);\n        let iter = IncorrectLenIter {\n            len: 0,\n            iter: (0..2),\n        };\n        // this should panic\n        assert!(std::panic::catch_unwind(|| vec.extend(iter, |_, _| {})).is_err());\n    }\n\n    // test |values| does not fit in the boxcar\n    #[test]\n    fn extend_over_max_capacity() {\n        let vec = Vec::<u32>::with_capacity(1, 1);\n        let count = MAX_ENTRIES as usize + 2;\n        let iter = std::iter::repeat(0).take(count);\n        assert!(std::panic::catch_unwind(|| vec.extend(iter, |_, _| {})).is_err());\n    }\n}\n"
  },
  {
    "path": "crates/atuin-nucleo/src/lib.rs",
    "content": "/*!\n`nucleo` is a high level crate that provides a high level matcher API that\nprovides a highly effective (parallel) matcher worker. It's designed to allow\nquickly plugging a fully featured (and faster) fzf/skim like fuzzy matcher into\nyour TUI application.\n\nIt's designed to run matching on a background threadpool while providing a\nsnapshot of the last complete match. That means the matcher can update the\nresults live while the user is typing while never blocking the main UI thread\n(beyond a user provided timeout). Nucleo also supports fully concurrent lock-free\n(and wait-free) streaming of input items.\n\nThe [`Nucleo`] struct serves as the main API entrypoint for this crate.\n\n# Status\n\nNucleo is used in the helix-editor and therefore has a large user base with lots\nor real world testing. The core matcher implementation is considered complete\nand is unlikely to see major changes. The `atuin-nucleo-matcher` crate is finished and\nready for widespread use, breaking changes should be very rare (a 1.0 release\nshould not be far away).\n\nWhile the high level `nucleo` crate also works well (and is also used in helix),\nthere are still additional features that will be added in the future. The high\nlevel crate also need better documentation and will likely see a few minor API\nchanges in the future.\n\n*/\nuse std::ops::{Bound, RangeBounds};\nuse std::sync::atomic::{self, AtomicBool, Ordering};\nuse std::sync::Arc;\nuse std::time::Duration;\n\n/// A filter predicate that determines whether an item should be included in matching.\n/// Return `true` to include the item, `false` to skip it.\npub type Filter<T> = Arc<dyn Fn(&T) -> bool + Send + Sync>;\n\n/// A scorer callback that computes the final ranking score for an item.\n/// Receives a reference to the item and its fuzzy match score.\n/// Returns the combined/external score used for sorting results.\npub type Scorer<T> = Arc<dyn Fn(&T, u32) -> u32 + Send + Sync>;\n\nuse parking_lot::Mutex;\nuse rayon::ThreadPool;\n\nuse crate::pattern::MultiPattern;\nuse crate::worker::Worker;\npub use atuin_nucleo_matcher::{chars, Config, Matcher, Utf32Str, Utf32String};\n\nmod boxcar;\nmod par_sort;\npub mod pattern;\nmod worker;\n\n#[cfg(test)]\nmod tests;\n\n/// A match candidate stored in a [`Nucleo`] worker.\npub struct Item<'a, T> {\n    pub data: &'a T,\n    pub matcher_columns: &'a [Utf32String],\n}\n\n/// A handle that allows adding new items to a [`Nucleo`] worker.\n///\n/// It's internally reference counted and can be cheaply cloned\n/// and sent across threads.\npub struct Injector<T> {\n    items: Arc<boxcar::Vec<T>>,\n    notify: Arc<dyn Fn() + Sync + Send>,\n}\n\nimpl<T> Clone for Injector<T> {\n    fn clone(&self) -> Self {\n        Injector {\n            items: self.items.clone(),\n            notify: self.notify.clone(),\n        }\n    }\n}\n\nimpl<T> Injector<T> {\n    /// Appends an element to the list of matched items.\n    /// This function is lock-free and wait-free.\n    pub fn push(&self, value: T, fill_columns: impl FnOnce(&T, &mut [Utf32String])) -> u32 {\n        let idx = self.items.push(value, fill_columns);\n        (self.notify)();\n        idx\n    }\n\n    /// Appends multiple elements to the list of matched items.\n    /// This function is lock-free and wait-free.\n    ///\n    /// You should favor this function over `push` if at least one of the following is true:\n    /// - the number of items you're adding can be computed beforehand and is typically larger\n    ///   than 1k\n    /// - you're able to batch incoming items\n    /// - you're adding items from multiple threads concurrently (this function results in less\n    ///   contention)\n    pub fn extend<I>(&self, values: I, fill_columns: impl Fn(&T, &mut [Utf32String]))\n    where\n        I: IntoIterator<Item = T> + ExactSizeIterator,\n    {\n        self.items.extend(values, fill_columns);\n        (self.notify)();\n    }\n\n    /// Returns the total number of items injected in the matcher. This might\n    /// not match the number of items in the match snapshot (if the matcher\n    /// is still running)\n    pub fn injected_items(&self) -> u32 {\n        self.items.count()\n    }\n\n    /// Returns a reference to the item at the given index.\n    ///\n    /// # Safety\n    ///\n    /// Item at `index` must be initialized. That means you must have observed\n    /// `push` returning this value or `get` returning `Some` for this value.\n    /// Just because a later index is initialized doesn't mean that this index\n    /// is initialized\n    pub unsafe fn get_unchecked(&self, index: u32) -> Item<'_, T> {\n        self.items.get_unchecked(index)\n    }\n\n    /// Returns a reference to the element at the given index.\n    pub fn get(&self, index: u32) -> Option<Item<'_, T>> {\n        self.items.get(index)\n    }\n}\n\n/// An [item](crate::Item) that was successfully matched by a [`Nucleo`] worker.\n#[derive(PartialEq, Eq, Debug, Clone, Copy)]\npub struct Match {\n    /// The raw fuzzy match score from the matcher.\n    pub score: u32,\n    /// The external/combined score used for sorting.\n    /// If no scorer callback is set, this equals `score`.\n    /// If a scorer callback is set, this is the value returned by the callback.\n    pub external_score: u32,\n    /// The index of the matched item in the item list.\n    pub idx: u32,\n}\n\n/// That status of a [`Nucleo`] worker after a match.\n#[derive(PartialEq, Eq, Debug, Clone, Copy)]\npub struct Status {\n    /// Whether the current snapshot has changed.\n    pub changed: bool,\n    /// Whether the matcher is still processing in the background.\n    pub running: bool,\n}\n\n/// A snapshot represent the results of a [`Nucleo`] worker after\n/// finishing a [`tick`](Nucleo::tick).\npub struct Snapshot<T: Sync + Send + 'static> {\n    item_count: u32,\n    matches: Vec<Match>,\n    pattern: MultiPattern,\n    items: Arc<boxcar::Vec<T>>,\n}\n\nimpl<T: Sync + Send + 'static> Snapshot<T> {\n    fn clear(&mut self, new_items: Arc<boxcar::Vec<T>>) {\n        self.item_count = 0;\n        self.matches.clear();\n        self.items = new_items\n    }\n\n    fn update(&mut self, worker: &Worker<T>) {\n        self.item_count = worker.item_count();\n        self.pattern.clone_from(&worker.pattern);\n        self.matches.clone_from(&worker.matches);\n        if !Arc::ptr_eq(&worker.items, &self.items) {\n            self.items = worker.items.clone()\n        }\n    }\n\n    /// Returns that total number of items\n    pub fn item_count(&self) -> u32 {\n        self.item_count\n    }\n\n    /// Returns the pattern which items were matched against\n    pub fn pattern(&self) -> &MultiPattern {\n        &self.pattern\n    }\n\n    /// Returns that number of items that matched the pattern\n    pub fn matched_item_count(&self) -> u32 {\n        self.matches.len() as u32\n    }\n\n    /// Returns an iterator over the items that correspond to a subrange of\n    /// all the matches in this snapshot.\n    ///\n    /// # Panics\n    /// Panics if `range` has a range bound that is larger than\n    /// the matched item count\n    pub fn matched_items(\n        &self,\n        range: impl RangeBounds<u32>,\n    ) -> impl ExactSizeIterator<Item = Item<'_, T>> + DoubleEndedIterator + '_ {\n        // TODO: use TAIT\n        let start = match range.start_bound() {\n            Bound::Included(&start) => start as usize,\n            Bound::Excluded(&start) => start as usize + 1,\n            Bound::Unbounded => 0,\n        };\n        let end = match range.end_bound() {\n            Bound::Included(&end) => end as usize + 1,\n            Bound::Excluded(&end) => end as usize,\n            Bound::Unbounded => self.matches.len(),\n        };\n        self.matches[start..end]\n            .iter()\n            .map(|&m| unsafe { self.items.get_unchecked(m.idx) })\n    }\n\n    /// Returns a reference to the item at the given index.\n    ///\n    /// # Safety\n    ///\n    /// Item at `index` must be initialized. That means you must have observed a\n    /// match with the corresponding index in this exact snapshot. Observing\n    /// a higher index is not enough as item indices can be non-contigously\n    /// initialized\n    #[inline]\n    pub unsafe fn get_item_unchecked(&self, index: u32) -> Item<'_, T> {\n        self.items.get_unchecked(index)\n    }\n\n    /// Returns a reference to the item at the given index.\n    ///\n    /// Returns `None` if the given `index` is not initialized. This function\n    /// is only guarteed to return `Some` for item indices that can be found in\n    /// the `matches` of this struct. Both smaller and larger indices may return\n    /// `None`.\n    #[inline]\n    pub fn get_item(&self, index: u32) -> Option<Item<'_, T>> {\n        self.items.get(index)\n    }\n\n    /// Return the matches corresponding to this snapshot.\n    #[inline]\n    pub fn matches(&self) -> &[Match] {\n        &self.matches\n    }\n\n    /// A convenience function to return the [`Item`] corresponding to the\n    /// `n`th match.\n    ///\n    /// Returns `None` if `n` is greater than or equal to the match count.\n    #[inline]\n    pub fn get_matched_item(&self, n: u32) -> Option<Item<'_, T>> {\n        // SAFETY: A match index is guaranteed to corresponding to a valid global index in this\n        // snapshot.\n        unsafe { Some(self.get_item_unchecked(self.matches.get(n as usize)?.idx)) }\n    }\n}\n\n#[repr(u8)]\n#[derive(Clone, Copy, PartialEq, Eq)]\nenum State {\n    Init,\n    /// items have been cleared but snapshot and items are still outdated\n    Cleared,\n    /// items are fresh\n    Fresh,\n}\n\nimpl State {\n    fn matcher_item_refs(self) -> usize {\n        match self {\n            State::Cleared => 1,\n            State::Init | State::Fresh => 2,\n        }\n    }\n\n    fn canceled(self) -> bool {\n        self != State::Fresh\n    }\n\n    fn cleared(self) -> bool {\n        self != State::Fresh\n    }\n}\n\n/// A high level matcher worker that quickly computes matches in a background\n/// threadpool.\npub struct Nucleo<T: Sync + Send + 'static> {\n    // the way the API is build we totally don't actually need these to be Arcs\n    // but this lets us avoid some unsafe\n    canceled: Arc<AtomicBool>,\n    should_notify: Arc<AtomicBool>,\n    worker: Arc<Mutex<Worker<T>>>,\n    pool: ThreadPool,\n    state: State,\n    items: Arc<boxcar::Vec<T>>,\n    notify: Arc<dyn Fn() + Sync + Send>,\n    snapshot: Snapshot<T>,\n    /// The pattern matched by this matcher. To update the match pattern\n    /// [`MultiPattern::reparse`](`pattern::MultiPattern::reparse`) should be used.\n    /// Note that the matcher worker will only become aware of the new pattern\n    /// after a call to [`tick`](Nucleo::tick).\n    pub pattern: MultiPattern,\n    /// Optional filter predicate. Items where filter returns false are skipped.\n    filter: Option<Filter<T>>,\n    /// Optional scorer callback. Returns combined score used for sorting.\n    scorer: Option<Scorer<T>>,\n    /// Flag indicating filter or scorer has changed and rescore is needed.\n    filter_scorer_changed: bool,\n}\n\nimpl<T: Sync + Send + 'static> Nucleo<T> {\n    /// Constructs a new `nucleo` worker threadpool with the provided `config`.\n    ///\n    /// `notify` is called every time new information is available and\n    /// [`tick`](Nucleo::tick) should be called. Note that `notify` is not\n    /// debounced, that should be handled by the downstream crate (for example\n    /// debouncing to only redraw at most every 1/60 seconds).\n    ///\n    /// If `None` is passed for the number of worker threads, nucleo will use\n    /// one thread per hardware thread.\n    ///\n    /// Nucleo can match items with multiple orthogonal properties. `columns`\n    /// indicates how many matching columns each item (and the pattern) has. The\n    /// number of columns cannot be changed after construction.\n    pub fn new(\n        config: Config,\n        notify: Arc<dyn Fn() + Sync + Send>,\n        num_threads: Option<usize>,\n        columns: u32,\n    ) -> Self {\n        let (pool, worker) = Worker::new(num_threads, config, notify.clone(), columns);\n        Self {\n            canceled: worker.canceled.clone(),\n            should_notify: worker.should_notify.clone(),\n            items: worker.items.clone(),\n            pool,\n            pattern: MultiPattern::new(columns as usize),\n            snapshot: Snapshot {\n                matches: Vec::with_capacity(2 * 1024),\n                pattern: MultiPattern::new(columns as usize),\n                item_count: 0,\n                items: worker.items.clone(),\n            },\n            worker: Arc::new(Mutex::new(worker)),\n            state: State::Init,\n            notify,\n            filter: None,\n            scorer: None,\n            filter_scorer_changed: false,\n        }\n    }\n\n    /// Returns the total number of active injectors\n    pub fn active_injectors(&self) -> usize {\n        Arc::strong_count(&self.items)\n            - self.state.matcher_item_refs()\n            - (Arc::ptr_eq(&self.snapshot.items, &self.items)) as usize\n    }\n\n    /// Returns a snapshot of the current matcher state.\n    pub fn snapshot(&self) -> &Snapshot<T> {\n        &self.snapshot\n    }\n\n    /// Returns an injector that can be used for adding candidates to the matcher.\n    pub fn injector(&self) -> Injector<T> {\n        Injector {\n            items: self.items.clone(),\n            notify: self.notify.clone(),\n        }\n    }\n\n    /// Restart the the item stream. Removes all items and disconnects all\n    /// previously created injectors from this instance. If `clear_snapshot`\n    /// is `true` then all items and matched are removed from the [`Snapshot`]\n    /// immediately. Otherwise the snapshot will keep the current matches until\n    /// the matcher has run again.\n    ///\n    /// # Note\n    ///\n    /// The injectors will continue to function but they will not affect this\n    /// instance anymore. The old items will only be dropped when all injectors\n    /// were dropped.\n    pub fn restart(&mut self, clear_snapshot: bool) {\n        self.canceled.store(true, Ordering::Relaxed);\n        self.items = Arc::new(boxcar::Vec::with_capacity(1024, self.items.columns()));\n        self.state = State::Cleared;\n        if clear_snapshot {\n            self.snapshot.clear(self.items.clone());\n        }\n    }\n\n    /// Update the internal configuration.\n    pub fn update_config(&mut self, config: Config) {\n        self.worker.lock().update_config(config)\n    }\n\n    // Set whether the matcher should sort search results by score after\n    // matching. Defaults to true.\n    pub fn sort_results(&mut self, sort_results: bool) {\n        self.worker.lock().sort_results(sort_results)\n    }\n\n    // Set whether the matcher should reverse the order of the input.\n    // Defaults to false.\n    pub fn reverse_items(&mut self, reverse_items: bool) {\n        self.worker.lock().reverse_items(reverse_items)\n    }\n\n    /// Set a filter predicate. Items where the filter returns `false` are\n    /// skipped during matching. This is applied before fuzzy matching, so\n    /// filtered items don't incur the cost of fuzzy matching.\n    ///\n    /// Setting a new filter triggers a rescore on the next [`tick`](Nucleo::tick).\n    ///\n    /// Pass `None` to remove the filter.\n    pub fn set_filter(&mut self, filter: Option<Filter<T>>) {\n        self.filter = filter;\n        self.filter_scorer_changed = true;\n    }\n\n    /// Set a scorer callback. The callback receives a reference to the item\n    /// and its fuzzy match score, and returns the combined score used for\n    /// sorting results.\n    ///\n    /// If no scorer is set, results are sorted by fuzzy match score.\n    ///\n    /// Setting a new scorer triggers a rescore on the next [`tick`](Nucleo::tick).\n    ///\n    /// Pass `None` to remove the scorer and use default fuzzy score sorting.\n    pub fn set_scorer(&mut self, scorer: Option<Scorer<T>>) {\n        self.scorer = scorer;\n        self.filter_scorer_changed = true;\n    }\n\n    /// The main way to interact with the matcher, this should be called\n    /// regularly (for example each time a frame is rendered). To avoid\n    /// excessive redraws this method will wait `timeout` milliseconds for the\n    /// worker thread to finish. It is recommend to set the timeout to 10ms.\n    pub fn tick(&mut self, timeout: u64) -> Status {\n        self.should_notify.store(false, atomic::Ordering::Relaxed);\n        let mut status = self.pattern.status();\n        // If filter or scorer changed, treat as rescore\n        if self.filter_scorer_changed {\n            if status == pattern::Status::Unchanged {\n                status = pattern::Status::Rescore;\n            }\n            self.filter_scorer_changed = false;\n        }\n        let canceled = status != pattern::Status::Unchanged || self.state.canceled();\n        let mut res = self.tick_inner(timeout, canceled, status);\n        if !canceled {\n            return res;\n        }\n        self.state = State::Fresh;\n        let status2 = self.tick_inner(timeout, false, pattern::Status::Unchanged);\n        res.changed |= status2.changed;\n        res.running = status2.running;\n        res\n    }\n\n    fn tick_inner(&mut self, timeout: u64, canceled: bool, status: pattern::Status) -> Status {\n        let mut inner = if canceled {\n            self.pattern.reset_status();\n            self.canceled.store(true, atomic::Ordering::Relaxed);\n            self.worker.lock_arc()\n        } else {\n            let Some(worker) = self.worker.try_lock_arc_for(Duration::from_millis(timeout)) else {\n                self.should_notify.store(true, Ordering::Release);\n                return Status {\n                    changed: false,\n                    running: true,\n                };\n            };\n            worker\n        };\n\n        let changed = inner.running;\n\n        let running = canceled || self.items.count() > inner.item_count();\n        if inner.running {\n            inner.running = false;\n            if !inner.was_canceled && !self.state.canceled() {\n                self.snapshot.update(&inner)\n            }\n        }\n        if running {\n            inner.pattern.clone_from(&self.pattern);\n            // Update filter and scorer in worker\n            inner.set_filter(self.filter.clone());\n            inner.set_scorer(self.scorer.clone());\n            self.canceled.store(false, atomic::Ordering::Relaxed);\n            if !canceled {\n                self.should_notify.store(true, atomic::Ordering::Release);\n            }\n            let cleared = self.state.cleared();\n            if cleared {\n                inner.items = self.items.clone();\n            }\n            self.pool\n                .spawn(move || unsafe { inner.run(status, cleared) })\n        }\n        Status { changed, running }\n    }\n}\n\nimpl<T: Sync + Send> Drop for Nucleo<T> {\n    fn drop(&mut self) {\n        // we ensure the worker quits before dropping items to ensure that\n        // the worker can always assume the items outlive it\n        self.canceled.store(true, atomic::Ordering::Relaxed);\n        let lock = self.worker.try_lock_for(Duration::from_secs(1));\n        if lock.is_none() {\n            unreachable!(\"thread pool failed to shutdown properly\")\n        }\n    }\n}\n"
  },
  {
    "path": "crates/atuin-nucleo/src/par_sort.rs",
    "content": "//! Parallel quicksort.\n//!\n//! This implementation is copied verbatim from `std::slice::sort_unstable` and then parallelized.\n//! The only difference from the original is that calls to `recurse` are executed in parallel using\n//! `rayon_core::join`.\n//! Further modified for nucleo to allow canceling the sort\n\n// Copyright (c) 2010 The Rust Project Developers\n//\n// Permission is hereby granted, free of charge, to any\n// person obtaining a copy of this software and associated\n// documentation files (the \"Software\"), to deal in the\n// Software without restriction, including without\n// limitation the rights to use, copy, modify, merge,\n// publish, distribute, sublicense, and/or sell copies of\n// the Software, and to permit persons to whom the Software\n// is furnished to do so, subject to the following\n// conditions:\n//\n// The above copyright notice and this permission notice\n// shall be included in all copies or substantial portions\n// of the Software.\n//\n// THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\n// ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\n// TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\n// PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\n// SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\n// CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\n// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\n// IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\n// DEALINGS IN THE SOFTWARE.\n\nuse std::cmp;\nuse std::mem::{self, MaybeUninit};\nuse std::ptr;\nuse std::sync::atomic::{self, AtomicBool};\n\n/// When dropped, copies from `src` into `dest`.\nstruct CopyOnDrop<T> {\n    src: *const T,\n    dest: *mut T,\n}\n\nimpl<T> Drop for CopyOnDrop<T> {\n    fn drop(&mut self) {\n        // SAFETY:  This is a helper class.\n        //          Please refer to its usage for correctness.\n        //          Namely, one must be sure that `src` and `dst` does not overlap as required by `ptr::copy_nonoverlapping`.\n        unsafe {\n            ptr::copy_nonoverlapping(self.src, self.dest, 1);\n        }\n    }\n}\n\n/// Shifts the first element to the right until it encounters a greater or equal element.\nfn shift_head<T, F>(v: &mut [T], is_less: &F)\nwhere\n    F: Fn(&T, &T) -> bool,\n{\n    let len = v.len();\n    // SAFETY: The unsafe operations below involves indexing without a bounds check (by offsetting a\n    // pointer) and copying memory (`ptr::copy_nonoverlapping`).\n    //\n    // a. Indexing:\n    //  1. We checked the size of the array to >=2.\n    //  2. All the indexing that we will do is always between {0 <= index < len} at most.\n    //\n    // b. Memory copying\n    //  1. We are obtaining pointers to references which are guaranteed to be valid.\n    //  2. They cannot overlap because we obtain pointers to difference indices of the slice.\n    //     Namely, `i` and `i-1`.\n    //  3. If the slice is properly aligned, the elements are properly aligned.\n    //     It is the caller's responsibility to make sure the slice is properly aligned.\n    //\n    // See comments below for further detail.\n    unsafe {\n        // If the first two elements are out-of-order...\n        if len >= 2 && is_less(v.get_unchecked(1), v.get_unchecked(0)) {\n            // Read the first element into a stack-allocated variable. If a following comparison\n            // operation panics, `hole` will get dropped and automatically write the element back\n            // into the slice.\n            let tmp = mem::ManuallyDrop::new(ptr::read(v.get_unchecked(0)));\n            let v = v.as_mut_ptr();\n            let mut hole = CopyOnDrop {\n                src: &*tmp,\n                dest: v.add(1),\n            };\n            ptr::copy_nonoverlapping(v.add(1), v.add(0), 1);\n\n            for i in 2..len {\n                if !is_less(&*v.add(i), &*tmp) {\n                    break;\n                }\n\n                // Move `i`-th element one place to the left, thus shifting the hole to the right.\n                ptr::copy_nonoverlapping(v.add(i), v.add(i - 1), 1);\n                hole.dest = v.add(i);\n            }\n            // `hole` gets dropped and thus copies `tmp` into the remaining hole in `v`.\n        }\n    }\n}\n\n/// Shifts the last element to the left until it encounters a smaller or equal element.\nfn shift_tail<T, F>(v: &mut [T], is_less: &F)\nwhere\n    F: Fn(&T, &T) -> bool,\n{\n    let len = v.len();\n    // SAFETY: The unsafe operations below involves indexing without a bound check (by offsetting a\n    // pointer) and copying memory (`ptr::copy_nonoverlapping`).\n    //\n    // a. Indexing:\n    //  1. We checked the size of the array to >= 2.\n    //  2. All the indexing that we will do is always between `0 <= index < len-1` at most.\n    //\n    // b. Memory copying\n    //  1. We are obtaining pointers to references which are guaranteed to be valid.\n    //  2. They cannot overlap because we obtain pointers to difference indices of the slice.\n    //     Namely, `i` and `i+1`.\n    //  3. If the slice is properly aligned, the elements are properly aligned.\n    //     It is the caller's responsibility to make sure the slice is properly aligned.\n    //\n    // See comments below for further detail.\n    unsafe {\n        // If the last two elements are out-of-order...\n        if len >= 2 && is_less(v.get_unchecked(len - 1), v.get_unchecked(len - 2)) {\n            // Read the last element into a stack-allocated variable. If a following comparison\n            // operation panics, `hole` will get dropped and automatically write the element back\n            // into the slice.\n            let tmp = mem::ManuallyDrop::new(ptr::read(v.get_unchecked(len - 1)));\n            let v = v.as_mut_ptr();\n            let mut hole = CopyOnDrop {\n                src: &*tmp,\n                dest: v.add(len - 2),\n            };\n            ptr::copy_nonoverlapping(v.add(len - 2), v.add(len - 1), 1);\n\n            for i in (0..len - 2).rev() {\n                if !is_less(&*tmp, &*v.add(i)) {\n                    break;\n                }\n\n                // Move `i`-th element one place to the right, thus shifting the hole to the left.\n                ptr::copy_nonoverlapping(v.add(i), v.add(i + 1), 1);\n                hole.dest = v.add(i);\n            }\n            // `hole` gets dropped and thus copies `tmp` into the remaining hole in `v`.\n        }\n    }\n}\n\n/// Partially sorts a slice by shifting several out-of-order elements around.\n///\n/// Returns `true` if the slice is sorted at the end. This function is *O*(*n*) worst-case.\n#[cold]\nfn partial_insertion_sort<T, F>(v: &mut [T], is_less: &F) -> bool\nwhere\n    F: Fn(&T, &T) -> bool,\n{\n    // Maximum number of adjacent out-of-order pairs that will get shifted.\n    const MAX_STEPS: usize = 5;\n    // If the slice is shorter than this, don't shift any elements.\n    const SHORTEST_SHIFTING: usize = 50;\n\n    let len = v.len();\n    let mut i = 1;\n\n    for _ in 0..MAX_STEPS {\n        // SAFETY: We already explicitly did the bound checking with `i < len`.\n        // All our subsequent indexing is only in the range `0 <= index < len`\n        unsafe {\n            // Find the next pair of adjacent out-of-order elements.\n            while i < len && !is_less(v.get_unchecked(i), v.get_unchecked(i - 1)) {\n                i += 1;\n            }\n        }\n\n        // Are we done?\n        if i == len {\n            return true;\n        }\n\n        // Don't shift elements on short arrays, that has a performance cost.\n        if len < SHORTEST_SHIFTING {\n            return false;\n        }\n\n        // Swap the found pair of elements. This puts them in correct order.\n        v.swap(i - 1, i);\n\n        // Shift the smaller element to the left.\n        shift_tail(&mut v[..i], is_less);\n        // Shift the greater element to the right.\n        shift_head(&mut v[i..], is_less);\n    }\n\n    // Didn't manage to sort the slice in the limited number of steps.\n    false\n}\n\n/// Sorts a slice using insertion sort, which is *O*(*n*^2) worst-case.\nfn insertion_sort<T, F>(v: &mut [T], is_less: &F)\nwhere\n    F: Fn(&T, &T) -> bool,\n{\n    for i in 1..v.len() {\n        shift_tail(&mut v[..i + 1], is_less);\n    }\n}\n\n/// Sorts `v` using heapsort, which guarantees *O*(*n* \\* log(*n*)) worst-case.\n#[cold]\nfn heapsort<T, F>(v: &mut [T], is_less: &F)\nwhere\n    F: Fn(&T, &T) -> bool,\n{\n    // This binary heap respects the invariant `parent >= child`.\n    let sift_down = |v: &mut [T], mut node| {\n        loop {\n            // Children of `node`.\n            let mut child = 2 * node + 1;\n            if child >= v.len() {\n                break;\n            }\n\n            // Choose the greater child.\n            if child + 1 < v.len() && is_less(&v[child], &v[child + 1]) {\n                child += 1;\n            }\n\n            // Stop if the invariant holds at `node`.\n            if !is_less(&v[node], &v[child]) {\n                break;\n            }\n\n            // Swap `node` with the greater child, move one step down, and continue sifting.\n            v.swap(node, child);\n            node = child;\n        }\n    };\n\n    // Build the heap in linear time.\n    for i in (0..v.len() / 2).rev() {\n        sift_down(v, i);\n    }\n\n    // Pop maximal elements from the heap.\n    for i in (1..v.len()).rev() {\n        v.swap(0, i);\n        sift_down(&mut v[..i], 0);\n    }\n}\n\n/// Partitions `v` into elements smaller than `pivot`, followed by elements greater than or equal\n/// to `pivot`.\n///\n/// Returns the number of elements smaller than `pivot`.\n///\n/// Partitioning is performed block-by-block in order to minimize the cost of branching operations.\n/// This idea is presented in the [BlockQuicksort][pdf] paper.\n///\n/// [pdf]: https://drops.dagstuhl.de/opus/volltexte/2016/6389/pdf/LIPIcs-ESA-2016-38.pdf\nfn partition_in_blocks<T, F>(v: &mut [T], pivot: &T, is_less: &F) -> usize\nwhere\n    F: Fn(&T, &T) -> bool,\n{\n    // Number of elements in a typical block.\n    const BLOCK: usize = 128;\n\n    // The partitioning algorithm repeats the following steps until completion:\n    //\n    // 1. Trace a block from the left side to identify elements greater than or equal to the pivot.\n    // 2. Trace a block from the right side to identify elements smaller than the pivot.\n    // 3. Exchange the identified elements between the left and right side.\n    //\n    // We keep the following variables for a block of elements:\n    //\n    // 1. `block` - Number of elements in the block.\n    // 2. `start` - Start pointer into the `offsets` array.\n    // 3. `end` - End pointer into the `offsets` array.\n    // 4. `offsets - Indices of out-of-order elements within the block.\n\n    // The current block on the left side (from `l` to `l.add(block_l)`).\n    let mut l = v.as_mut_ptr();\n    let mut block_l = BLOCK;\n    let mut start_l = ptr::null_mut();\n    let mut end_l = ptr::null_mut();\n    let mut offsets_l = [MaybeUninit::<u8>::uninit(); BLOCK];\n\n    // The current block on the right side (from `r.sub(block_r)` to `r`).\n    // SAFETY: The documentation for .add() specifically mention that `vec.as_ptr().add(vec.len())` is always safe`\n    let mut r = unsafe { l.add(v.len()) };\n    let mut block_r = BLOCK;\n    let mut start_r = ptr::null_mut();\n    let mut end_r = ptr::null_mut();\n    let mut offsets_r = [MaybeUninit::<u8>::uninit(); BLOCK];\n\n    // FIXME: When we get VLAs, try creating one array of length `min(v.len(), 2 * BLOCK)` rather\n    // than two fixed-size arrays of length `BLOCK`. VLAs might be more cache-efficient.\n\n    // Returns the number of elements between pointers `l` (inclusive) and `r` (exclusive).\n    fn width<T>(l: *mut T, r: *mut T) -> usize {\n        assert!(mem::size_of::<T>() > 0);\n        // FIXME: this should *likely* use `offset_from`, but more\n        // investigation is needed (including running tests in miri).\n        // TODO unstable: (r.addr() - l.addr()) / mem::size_of::<T>()\n        (r as usize - l as usize) / mem::size_of::<T>()\n    }\n\n    loop {\n        // We are done with partitioning block-by-block when `l` and `r` get very close. Then we do\n        // some patch-up work in order to partition the remaining elements in between.\n        let is_done = width(l, r) <= 2 * BLOCK;\n\n        if is_done {\n            // Number of remaining elements (still not compared to the pivot).\n            let mut rem = width(l, r);\n            if start_l < end_l || start_r < end_r {\n                rem -= BLOCK;\n            }\n\n            // Adjust block sizes so that the left and right block don't overlap, but get perfectly\n            // aligned to cover the whole remaining gap.\n            if start_l < end_l {\n                block_r = rem;\n            } else if start_r < end_r {\n                block_l = rem;\n            } else {\n                // There were the same number of elements to switch on both blocks during the last\n                // iteration, so there are no remaining elements on either block. Cover the remaining\n                // items with roughly equally-sized blocks.\n                block_l = rem / 2;\n                block_r = rem - block_l;\n            }\n            debug_assert!(block_l <= BLOCK && block_r <= BLOCK);\n            debug_assert!(width(l, r) == block_l + block_r);\n        }\n\n        if start_l == end_l {\n            // Trace `block_l` elements from the left side.\n            // TODO unstable: start_l = MaybeUninit::slice_as_mut_ptr(&mut offsets_l);\n            start_l = offsets_l.as_mut_ptr() as *mut u8;\n            end_l = start_l;\n            let mut elem = l;\n\n            for i in 0..block_l {\n                // SAFETY: The unsafety operations below involve the usage of the `offset`.\n                //         According to the conditions required by the function, we satisfy them because:\n                //         1. `offsets_l` is stack-allocated, and thus considered separate allocated object.\n                //         2. The function `is_less` returns a `bool`.\n                //            Casting a `bool` will never overflow `isize`.\n                //         3. We have guaranteed that `block_l` will be `<= BLOCK`.\n                //            Plus, `end_l` was initially set to the begin pointer of `offsets_` which was declared on the stack.\n                //            Thus, we know that even in the worst case (all invocations of `is_less` returns false) we will only be at most 1 byte pass the end.\n                //        Another unsafety operation here is dereferencing `elem`.\n                //        However, `elem` was initially the begin pointer to the slice which is always valid.\n                unsafe {\n                    // Branchless comparison.\n                    *end_l = i as u8;\n                    end_l = end_l.offset(!is_less(&*elem, pivot) as isize);\n                    elem = elem.offset(1);\n                }\n            }\n        }\n\n        if start_r == end_r {\n            // Trace `block_r` elements from the right side.\n            // TODO unstable: start_r = MaybeUninit::slice_as_mut_ptr(&mut offsets_r);\n            start_r = offsets_r.as_mut_ptr() as *mut u8;\n            end_r = start_r;\n            let mut elem = r;\n\n            for i in 0..block_r {\n                // SAFETY: The unsafety operations below involve the usage of the `offset`.\n                //         According to the conditions required by the function, we satisfy them because:\n                //         1. `offsets_r` is stack-allocated, and thus considered separate allocated object.\n                //         2. The function `is_less` returns a `bool`.\n                //            Casting a `bool` will never overflow `isize`.\n                //         3. We have guaranteed that `block_r` will be `<= BLOCK`.\n                //            Plus, `end_r` was initially set to the begin pointer of `offsets_` which was declared on the stack.\n                //            Thus, we know that even in the worst case (all invocations of `is_less` returns true) we will only be at most 1 byte pass the end.\n                //        Another unsafety operation here is dereferencing `elem`.\n                //        However, `elem` was initially `1 * sizeof(T)` past the end and we decrement it by `1 * sizeof(T)` before accessing it.\n                //        Plus, `block_r` was asserted to be less than `BLOCK` and `elem` will therefore at most be pointing to the beginning of the slice.\n                unsafe {\n                    // Branchless comparison.\n                    elem = elem.offset(-1);\n                    *end_r = i as u8;\n                    end_r = end_r.offset(is_less(&*elem, pivot) as isize);\n                }\n            }\n        }\n\n        // Number of out-of-order elements to swap between the left and right side.\n        let count = cmp::min(width(start_l, end_l), width(start_r, end_r));\n\n        if count > 0 {\n            macro_rules! left {\n                () => {\n                    l.offset(*start_l as isize)\n                };\n            }\n            macro_rules! right {\n                () => {\n                    r.offset(-(*start_r as isize) - 1)\n                };\n            }\n\n            // Instead of swapping one pair at the time, it is more efficient to perform a cyclic\n            // permutation. This is not strictly equivalent to swapping, but produces a similar\n            // result using fewer memory operations.\n\n            // SAFETY: The use of `ptr::read` is valid because there is at least one element in\n            // both `offsets_l` and `offsets_r`, so `left!` is a valid pointer to read from.\n            //\n            // The uses of `left!` involve calls to `offset` on `l`, which points to the\n            // beginning of `v`. All the offsets pointed-to by `start_l` are at most `block_l`, so\n            // these `offset` calls are safe as all reads are within the block. The same argument\n            // applies for the uses of `right!`.\n            //\n            // The calls to `start_l.offset` are valid because there are at most `count-1` of them,\n            // plus the final one at the end of the unsafe block, where `count` is the minimum number\n            // of collected offsets in `offsets_l` and `offsets_r`, so there is no risk of there not\n            // being enough elements. The same reasoning applies to the calls to `start_r.offset`.\n            //\n            // The calls to `copy_nonoverlapping` are safe because `left!` and `right!` are guaranteed\n            // not to overlap, and are valid because of the reasoning above.\n            unsafe {\n                let tmp = ptr::read(left!());\n                ptr::copy_nonoverlapping(right!(), left!(), 1);\n\n                for _ in 1..count {\n                    start_l = start_l.offset(1);\n                    ptr::copy_nonoverlapping(left!(), right!(), 1);\n                    start_r = start_r.offset(1);\n                    ptr::copy_nonoverlapping(right!(), left!(), 1);\n                }\n\n                ptr::copy_nonoverlapping(&tmp, right!(), 1);\n                mem::forget(tmp);\n                start_l = start_l.offset(1);\n                start_r = start_r.offset(1);\n            }\n        }\n\n        if start_l == end_l {\n            // All out-of-order elements in the left block were moved. Move to the next block.\n\n            // block-width-guarantee\n            // SAFETY: if `!is_done` then the slice width is guaranteed to be at least `2*BLOCK` wide. There\n            // are at most `BLOCK` elements in `offsets_l` because of its size, so the `offset` operation is\n            // safe. Otherwise, the debug assertions in the `is_done` case guarantee that\n            // `width(l, r) == block_l + block_r`, namely, that the block sizes have been adjusted to account\n            // for the smaller number of remaining elements.\n            l = unsafe { l.add(block_l) };\n        }\n\n        if start_r == end_r {\n            // All out-of-order elements in the right block were moved. Move to the previous block.\n\n            // SAFETY: Same argument as [block-width-guarantee]. Either this is a full block `2*BLOCK`-wide,\n            // or `block_r` has been adjusted for the last handful of elements.\n            r = unsafe { r.offset(-(block_r as isize)) };\n        }\n\n        if is_done {\n            break;\n        }\n    }\n\n    // All that remains now is at most one block (either the left or the right) with out-of-order\n    // elements that need to be moved. Such remaining elements can be simply shifted to the end\n    // within their block.\n\n    if start_l < end_l {\n        // The left block remains.\n        // Move its remaining out-of-order elements to the far right.\n        debug_assert_eq!(width(l, r), block_l);\n        while start_l < end_l {\n            // remaining-elements-safety\n            // SAFETY: while the loop condition holds there are still elements in `offsets_l`, so it\n            // is safe to point `end_l` to the previous element.\n            //\n            // The `ptr::swap` is safe if both its arguments are valid for reads and writes:\n            //  - Per the debug assert above, the distance between `l` and `r` is `block_l`\n            //    elements, so there can be at most `block_l` remaining offsets between `start_l`\n            //    and `end_l`. This means `r` will be moved at most `block_l` steps back, which\n            //    makes the `r.offset` calls valid (at that point `l == r`).\n            //  - `offsets_l` contains valid offsets into `v` collected during the partitioning of\n            //    the last block, so the `l.offset` calls are valid.\n            unsafe {\n                end_l = end_l.offset(-1);\n                ptr::swap(l.offset(*end_l as isize), r.offset(-1));\n                r = r.offset(-1);\n            }\n        }\n        width(v.as_mut_ptr(), r)\n    } else if start_r < end_r {\n        // The right block remains.\n        // Move its remaining out-of-order elements to the far left.\n        debug_assert_eq!(width(l, r), block_r);\n        while start_r < end_r {\n            // SAFETY: See the reasoning in [remaining-elements-safety].\n            unsafe {\n                end_r = end_r.offset(-1);\n                ptr::swap(l, r.offset(-(*end_r as isize) - 1));\n                l = l.offset(1);\n            }\n        }\n        width(v.as_mut_ptr(), l)\n    } else {\n        // Nothing else to do, we're done.\n        width(v.as_mut_ptr(), l)\n    }\n}\n\n/// Partitions `v` into elements smaller than `v[pivot]`, followed by elements greater than or\n/// equal to `v[pivot]`.\n///\n/// Returns a tuple of:\n///\n/// 1. Number of elements smaller than `v[pivot]`.\n/// 2. True if `v` was already partitioned.\nfn partition<T, F>(v: &mut [T], pivot: usize, is_less: &F) -> (usize, bool)\nwhere\n    F: Fn(&T, &T) -> bool,\n{\n    let (mid, was_partitioned) = {\n        // Place the pivot at the beginning of slice.\n        v.swap(0, pivot);\n        let (pivot, v) = v.split_at_mut(1);\n        let pivot = &mut pivot[0];\n\n        // Read the pivot into a stack-allocated variable for efficiency. If a following comparison\n        // operation panics, the pivot will be automatically written back into the slice.\n\n        // SAFETY: `pivot` is a reference to the first element of `v`, so `ptr::read` is safe.\n        let tmp = mem::ManuallyDrop::new(unsafe { ptr::read(pivot) });\n        let _pivot_guard = CopyOnDrop {\n            src: &*tmp,\n            dest: pivot,\n        };\n        let pivot = &*tmp;\n\n        // Find the first pair of out-of-order elements.\n        let mut l = 0;\n        let mut r = v.len();\n\n        // SAFETY: The unsafety below involves indexing an array.\n        // For the first one: We already do the bounds checking here with `l < r`.\n        // For the second one: We initially have `l == 0` and `r == v.len()` and we checked that `l < r` at every indexing operation.\n        //                     From here we know that `r` must be at least `r == l` which was shown to be valid from the first one.\n        unsafe {\n            // Find the first element greater than or equal to the pivot.\n            while l < r && is_less(v.get_unchecked(l), pivot) {\n                l += 1;\n            }\n\n            // Find the last element smaller that the pivot.\n            while l < r && !is_less(v.get_unchecked(r - 1), pivot) {\n                r -= 1;\n            }\n        }\n\n        (\n            l + partition_in_blocks(&mut v[l..r], pivot, is_less),\n            l >= r,\n        )\n\n        // `_pivot_guard` goes out of scope and writes the pivot (which is a stack-allocated\n        // variable) back into the slice where it originally was. This step is critical in ensuring\n        // safety!\n    };\n\n    // Place the pivot between the two partitions.\n    v.swap(0, mid);\n\n    (mid, was_partitioned)\n}\n\n/// Partitions `v` into elements equal to `v[pivot]` followed by elements greater than `v[pivot]`.\n///\n/// Returns the number of elements equal to the pivot. It is assumed that `v` does not contain\n/// elements smaller than the pivot.\nfn partition_equal<T, F>(v: &mut [T], pivot: usize, is_less: &F) -> usize\nwhere\n    F: Fn(&T, &T) -> bool,\n{\n    // Place the pivot at the beginning of slice.\n    v.swap(0, pivot);\n    let (pivot, v) = v.split_at_mut(1);\n    let pivot = &mut pivot[0];\n\n    // Read the pivot into a stack-allocated variable for efficiency. If a following comparison\n    // operation panics, the pivot will be automatically written back into the slice.\n    // SAFETY: The pointer here is valid because it is obtained from a reference to a slice.\n    let tmp = mem::ManuallyDrop::new(unsafe { ptr::read(pivot) });\n    let _pivot_guard = CopyOnDrop {\n        src: &*tmp,\n        dest: pivot,\n    };\n    let pivot = &*tmp;\n\n    // Now partition the slice.\n    let mut l = 0;\n    let mut r = v.len();\n    loop {\n        // SAFETY: The unsafety below involves indexing an array.\n        // For the first one: We already do the bounds checking here with `l < r`.\n        // For the second one: We initially have `l == 0` and `r == v.len()` and we checked that `l < r` at every indexing operation.\n        //                     From here we know that `r` must be at least `r == l` which was shown to be valid from the first one.\n        unsafe {\n            // Find the first element greater than the pivot.\n            while l < r && !is_less(pivot, v.get_unchecked(l)) {\n                l += 1;\n            }\n\n            // Find the last element equal to the pivot.\n            while l < r && is_less(pivot, v.get_unchecked(r - 1)) {\n                r -= 1;\n            }\n\n            // Are we done?\n            if l >= r {\n                break;\n            }\n\n            // Swap the found pair of out-of-order elements.\n            r -= 1;\n            let ptr = v.as_mut_ptr();\n            ptr::swap(ptr.add(l), ptr.add(r));\n            l += 1;\n        }\n    }\n\n    // We found `l` elements equal to the pivot. Add 1 to account for the pivot itself.\n    l + 1\n\n    // `_pivot_guard` goes out of scope and writes the pivot (which is a stack-allocated variable)\n    // back into the slice where it originally was. This step is critical in ensuring safety!\n}\n\n/// Scatters some elements around in an attempt to break patterns that might cause imbalanced\n/// partitions in quicksort.\n#[cold]\nfn break_patterns<T>(v: &mut [T]) {\n    let len = v.len();\n    if len >= 8 {\n        // Pseudorandom number generator from the \"Xorshift RNGs\" paper by George Marsaglia.\n        let mut random = len as u32;\n        let mut gen_u32 = || {\n            random ^= random << 13;\n            random ^= random >> 17;\n            random ^= random << 5;\n            random\n        };\n        let mut gen_usize = || {\n            if usize::BITS <= 32 {\n                gen_u32() as usize\n            } else {\n                (((gen_u32() as u64) << 32) | (gen_u32() as u64)) as usize\n            }\n        };\n\n        // Take random numbers modulo this number.\n        // The number fits into `usize` because `len` is not greater than `isize::MAX`.\n        let modulus = len.next_power_of_two();\n\n        // Some pivot candidates will be in the nearby of this index. Let's randomize them.\n        let pos = len / 4 * 2;\n\n        for i in 0..3 {\n            // Generate a random number modulo `len`. However, in order to avoid costly operations\n            // we first take it modulo a power of two, and then decrease by `len` until it fits\n            // into the range `[0, len - 1]`.\n            let mut other = gen_usize() & (modulus - 1);\n\n            // `other` is guaranteed to be less than `2 * len`.\n            if other >= len {\n                other -= len;\n            }\n\n            v.swap(pos - 1 + i, other);\n        }\n    }\n}\n\n/// Chooses a pivot in `v` and returns the index and `true` if the slice is likely already sorted.\n///\n/// Elements in `v` might be reordered in the process.\nfn choose_pivot<T, F>(v: &mut [T], is_less: &F) -> (usize, bool)\nwhere\n    F: Fn(&T, &T) -> bool,\n{\n    // Minimum length to choose the median-of-medians method.\n    // Shorter slices use the simple median-of-three method.\n    const SHORTEST_MEDIAN_OF_MEDIANS: usize = 50;\n    // Maximum number of swaps that can be performed in this function.\n    const MAX_SWAPS: usize = 4 * 3;\n\n    let len = v.len();\n\n    // Three indices near which we are going to choose a pivot.\n    #[allow(clippy::identity_op)]\n    let mut a = len / 4 * 1;\n    let mut b = len / 4 * 2;\n    let mut c = len / 4 * 3;\n\n    // Counts the total number of swaps we are about to perform while sorting indices.\n    let mut swaps = 0;\n\n    if len >= 8 {\n        // Swaps indices so that `v[a] <= v[b]`.\n        // SAFETY: `len >= 8` so there are at least two elements in the neighborhoods of\n        // `a`, `b` and `c`. This means the three calls to `sort_adjacent` result in\n        // corresponding calls to `sort3` with valid 3-item neighborhoods around each\n        // pointer, which in turn means the calls to `sort2` are done with valid\n        // references. Thus the `v.get_unchecked` calls are safe, as is the `ptr::swap`\n        // call.\n        let mut sort2 = |a: &mut usize, b: &mut usize| unsafe {\n            if is_less(v.get_unchecked(*b), v.get_unchecked(*a)) {\n                ptr::swap(a, b);\n                swaps += 1;\n            }\n        };\n\n        // Swaps indices so that `v[a] <= v[b] <= v[c]`.\n        let mut sort3 = |a: &mut usize, b: &mut usize, c: &mut usize| {\n            sort2(a, b);\n            sort2(b, c);\n            sort2(a, b);\n        };\n\n        if len >= SHORTEST_MEDIAN_OF_MEDIANS {\n            // Finds the median of `v[a - 1], v[a], v[a + 1]` and stores the index into `a`.\n            let mut sort_adjacent = |a: &mut usize| {\n                let tmp = *a;\n                sort3(&mut (tmp - 1), a, &mut (tmp + 1));\n            };\n\n            // Find medians in the neighborhoods of `a`, `b`, and `c`.\n            sort_adjacent(&mut a);\n            sort_adjacent(&mut b);\n            sort_adjacent(&mut c);\n        }\n\n        // Find the median among `a`, `b`, and `c`.\n        sort3(&mut a, &mut b, &mut c);\n    }\n\n    if swaps < MAX_SWAPS {\n        (b, swaps == 0)\n    } else {\n        // The maximum number of swaps was performed. Chances are the slice is descending or mostly\n        // descending, so reversing will probably help sort it faster.\n        v.reverse();\n        (len - 1 - b, true)\n    }\n}\n\n/// Sorts `v` recursively.\n///\n/// If the slice had a predecessor in the original array, it is specified as `pred`.\n///\n/// `limit` is the number of allowed imbalanced partitions before switching to `heapsort`. If zero,\n/// this function will immediately switch to heapsort.\nfn recurse<'a, T, F>(\n    mut v: &'a mut [T],\n    is_less: &F,\n    mut pred: Option<&'a mut T>,\n    mut limit: u32,\n    canceled: &AtomicBool,\n) -> bool\nwhere\n    T: Send,\n    F: Fn(&T, &T) -> bool + Sync,\n{\n    // Slices of up to this length get sorted using insertion sort.\n    const MAX_INSERTION: usize = 20;\n    // If both partitions are up to this length, we continue sequentially. This number is as small\n    // as possible but so that the overhead of Rayon's task scheduling is still negligible.\n    const MAX_SEQUENTIAL: usize = 2000;\n\n    // True if the last partitioning was reasonably balanced.\n    let mut was_balanced = true;\n    // True if the last partitioning didn't shuffle elements (the slice was already partitioned).\n    let mut was_partitioned = true;\n\n    loop {\n        let len = v.len();\n\n        // Very short slices get sorted using insertion sort.\n        if len <= MAX_INSERTION {\n            insertion_sort(v, is_less);\n            return false;\n        }\n\n        // If too many bad pivot choices were made, simply fall back to heapsort in order to\n        // guarantee `O(n * log(n))` worst-case.\n        if limit == 0 {\n            heapsort(v, is_less);\n            return false;\n        }\n\n        // If the last partitioning was imbalanced, try breaking patterns in the slice by shuffling\n        // some elements around. Hopefully we'll choose a better pivot this time.\n        if !was_balanced {\n            break_patterns(v);\n            limit -= 1;\n        }\n\n        // Choose a pivot and try guessing whether the slice is already sorted.\n        let (pivot, likely_sorted) = choose_pivot(v, is_less);\n\n        // If the last partitioning was decently balanced and didn't shuffle elements, and if pivot\n        // selection predicts the slice is likely already sorted...\n        if was_balanced && was_partitioned && likely_sorted {\n            // Try identifying several out-of-order elements and shifting them to correct\n            // positions. If the slice ends up being completely sorted, we're done.\n            if partial_insertion_sort(v, is_less) {\n                return false;\n            }\n        }\n\n        // If the chosen pivot is equal to the predecessor, then it's the smallest element in the\n        // slice. Partition the slice into elements equal to and elements greater than the pivot.\n        // This case is usually hit when the slice contains many duplicate elements.\n        if let Some(ref p) = pred {\n            if !is_less(p, &v[pivot]) {\n                let mid = partition_equal(v, pivot, is_less);\n\n                // Continue sorting elements greater than the pivot.\n                v = &mut v[mid..];\n                continue;\n            }\n        }\n\n        // Partition the slice.\n        let (mid, was_p) = partition(v, pivot, is_less);\n        was_balanced = cmp::min(mid, len - mid) >= len / 8;\n        was_partitioned = was_p;\n\n        // Split the slice into `left`, `pivot`, and `right`.\n        let (left, right) = v.split_at_mut(mid);\n        let (pivot, right) = right.split_at_mut(1);\n        let pivot = &mut pivot[0];\n\n        if cmp::max(left.len(), right.len()) <= MAX_SEQUENTIAL {\n            // Recurse into the shorter side only in order to minimize the total number of recursive\n            // calls and consume less stack space. Then just continue with the longer side (this is\n            // akin to tail recursion).\n            if left.len() < right.len() {\n                recurse(left, is_less, pred, limit, canceled);\n                v = right;\n                pred = Some(pivot);\n            } else {\n                recurse(right, is_less, Some(pivot), limit, canceled);\n                v = left;\n            }\n        } else if canceled.load(atomic::Ordering::Relaxed) {\n            break true;\n        } else {\n            // Sort the left and right half in parallel.\n            let (canceled1, canceled2) = rayon::join(\n                || recurse(left, is_less, pred, limit, canceled),\n                || recurse(right, is_less, Some(pivot), limit, canceled),\n            );\n            break canceled1 | canceled2;\n        }\n    }\n}\n\n/// Sorts `v` using pattern-defeating quicksort in parallel.\n///\n/// The algorithm is unstable, in-place, and *O*(*n* \\* log(*n*)) worst-case.\npub(crate) fn par_quicksort<T, F>(v: &mut [T], is_less: F, canceled: &AtomicBool) -> bool\nwhere\n    T: Send,\n    F: Fn(&T, &T) -> bool + Sync,\n{\n    // Sorting has no meaningful behavior on zero-sized types.\n    if mem::size_of::<T>() == 0 {\n        return false;\n    }\n    if canceled.load(atomic::Ordering::Relaxed) {\n        return true;\n    }\n\n    // Limit the number of imbalanced partitions to `floor(log2(len)) + 1`.\n    let limit = usize::BITS - v.len().leading_zeros();\n\n    recurse(v, &is_less, None, limit, canceled)\n}\n"
  },
  {
    "path": "crates/atuin-nucleo/src/pattern/tests.rs",
    "content": "use atuin_nucleo_matcher::pattern::{CaseMatching, Normalization};\n\nuse crate::pattern::{MultiPattern, Status};\n\n#[test]\nfn append() {\n    let mut pat = MultiPattern::new(1);\n    pat.reparse(0, \"!\", CaseMatching::Smart, Normalization::Smart, true);\n    assert_eq!(pat.status(), Status::Update);\n    pat.reparse(0, \"!f\", CaseMatching::Smart, Normalization::Smart, true);\n    assert_eq!(pat.status(), Status::Update);\n    pat.reparse(0, \"!fo\", CaseMatching::Smart, Normalization::Smart, true);\n    assert_eq!(pat.status(), Status::Rescore);\n}\n"
  },
  {
    "path": "crates/atuin-nucleo/src/pattern.rs",
    "content": "pub use atuin_nucleo_matcher::pattern::{Atom, AtomKind, CaseMatching, Normalization, Pattern};\nuse atuin_nucleo_matcher::{Matcher, Utf32String};\n\n#[cfg(test)]\nmod tests;\n\n#[derive(Debug, PartialEq, Eq, Clone, Copy, PartialOrd, Ord, Default)]\npub(crate) enum Status {\n    #[default]\n    Unchanged,\n    Update,\n    Rescore,\n}\n\n#[derive(Debug)]\npub struct MultiPattern {\n    cols: Vec<(Pattern, Status)>,\n}\n\nimpl Clone for MultiPattern {\n    fn clone(&self) -> Self {\n        Self {\n            cols: self.cols.clone(),\n        }\n    }\n\n    fn clone_from(&mut self, source: &Self) {\n        self.cols.clone_from(&source.cols)\n    }\n}\n\nimpl MultiPattern {\n    /// Creates a multi pattern with `columns` empty column patterns.\n    pub fn new(columns: usize) -> Self {\n        Self {\n            cols: vec![Default::default(); columns],\n        }\n    }\n\n    /// Reparses a column. By specifying `append` the caller promises that text passed\n    /// to the previous `reparse` invocation is a prefix of `new_text`. This enables\n    /// additional optimizations but can lead to missing matches if an incorrect value\n    /// is passed.\n    pub fn reparse(\n        &mut self,\n        column: usize,\n        new_text: &str,\n        case_matching: CaseMatching,\n        normalization: Normalization,\n        append: bool,\n    ) {\n        let old_status = self.cols[column].1;\n        if append\n            && old_status != Status::Rescore\n            && self.cols[column]\n                .0\n                .atoms\n                .last()\n                .is_none_or(|last| !last.negative)\n        {\n            self.cols[column].1 = Status::Update;\n        } else {\n            self.cols[column].1 = Status::Rescore;\n        }\n        self.cols[column]\n            .0\n            .reparse(new_text, case_matching, normalization);\n    }\n\n    pub fn column_pattern(&self, column: usize) -> &Pattern {\n        &self.cols[column].0\n    }\n\n    pub(crate) fn status(&self) -> Status {\n        self.cols\n            .iter()\n            .map(|&(_, status)| status)\n            .max()\n            .unwrap_or(Status::Unchanged)\n    }\n\n    pub(crate) fn reset_status(&mut self) {\n        for (_, status) in &mut self.cols {\n            *status = Status::Unchanged\n        }\n    }\n\n    pub fn score(&self, haystack: &[Utf32String], matcher: &mut Matcher) -> Option<u32> {\n        // TODO: weight columns?\n        let mut score = 0;\n        for ((pattern, _), haystack) in self.cols.iter().zip(haystack) {\n            score += pattern.score(haystack.slice(..), matcher)?\n        }\n        Some(score)\n    }\n\n    pub fn is_empty(&self) -> bool {\n        self.cols.iter().all(|(pat, _)| pat.atoms.is_empty())\n    }\n}\n"
  },
  {
    "path": "crates/atuin-nucleo/src/tests.rs",
    "content": "use std::sync::Arc;\n\nuse atuin_nucleo_matcher::Config;\n\nuse crate::{pattern, Nucleo};\n\n#[test]\nfn active_injector_count() {\n    let mut nucleo: Nucleo<()> = Nucleo::new(Config::DEFAULT, Arc::new(|| ()), Some(1), 1);\n    assert_eq!(nucleo.active_injectors(), 0);\n    let injector = nucleo.injector();\n    assert_eq!(nucleo.active_injectors(), 1);\n    let injector2 = nucleo.injector();\n    assert_eq!(nucleo.active_injectors(), 2);\n    drop(injector2);\n    assert_eq!(nucleo.active_injectors(), 1);\n    nucleo.restart(false);\n    assert_eq!(nucleo.active_injectors(), 0);\n    let injector3 = nucleo.injector();\n    assert_eq!(nucleo.active_injectors(), 1);\n    nucleo.tick(0);\n    assert_eq!(nucleo.active_injectors(), 1);\n    drop(injector);\n    assert_eq!(nucleo.active_injectors(), 1);\n    drop(injector3);\n    assert_eq!(nucleo.active_injectors(), 0);\n}\n\n#[derive(Clone, Debug)]\nstruct TestItem {\n    text: String,\n    category: u32,\n    priority: u32,\n}\n\n#[test]\nfn filter_excludes_items() {\n    let mut nucleo: Nucleo<TestItem> = Nucleo::new(Config::DEFAULT, Arc::new(|| ()), Some(1), 1);\n    let injector = nucleo.injector();\n\n    // Add items with different categories\n    injector.push(\n        TestItem {\n            text: \"apple\".into(),\n            category: 1,\n            priority: 10,\n        },\n        |item, cols| cols[0] = item.text.clone().into(),\n    );\n    injector.push(\n        TestItem {\n            text: \"apricot\".into(),\n            category: 2,\n            priority: 20,\n        },\n        |item, cols| cols[0] = item.text.clone().into(),\n    );\n    injector.push(\n        TestItem {\n            text: \"avocado\".into(),\n            category: 1,\n            priority: 30,\n        },\n        |item, cols| cols[0] = item.text.clone().into(),\n    );\n\n    // Search without filter - should get all 3\n    nucleo.pattern.reparse(\n        0,\n        \"a\",\n        pattern::CaseMatching::Ignore,\n        pattern::Normalization::Smart,\n        false,\n    );\n    while nucleo.tick(10).running {}\n    assert_eq!(nucleo.snapshot().matched_item_count(), 3);\n\n    // Set filter to only include category 1\n    nucleo.set_filter(Some(Arc::new(|item: &TestItem| item.category == 1)));\n\n    // Search again - should only get 2 items (apple, avocado)\n    while nucleo.tick(10).running {}\n    assert_eq!(nucleo.snapshot().matched_item_count(), 2);\n\n    // Verify the items are correct\n    let items: Vec<_> = nucleo\n        .snapshot()\n        .matched_items(..)\n        .map(|i| i.data.text.clone())\n        .collect();\n    assert!(items.contains(&\"apple\".to_string()));\n    assert!(items.contains(&\"avocado\".to_string()));\n    assert!(!items.contains(&\"apricot\".to_string()));\n\n    // Remove filter - should get all 3 again\n    nucleo.set_filter(None);\n    while nucleo.tick(10).running {}\n    assert_eq!(nucleo.snapshot().matched_item_count(), 3);\n}\n\n#[test]\nfn scorer_affects_sort_order() {\n    let mut nucleo: Nucleo<TestItem> = Nucleo::new(Config::DEFAULT, Arc::new(|| ()), Some(1), 1);\n    let injector = nucleo.injector();\n\n    // Add items with different priorities\n    injector.push(\n        TestItem {\n            text: \"banana\".into(),\n            category: 1,\n            priority: 10,\n        },\n        |item, cols| cols[0] = item.text.clone().into(),\n    );\n    injector.push(\n        TestItem {\n            text: \"blueberry\".into(),\n            category: 1,\n            priority: 100,\n        },\n        |item, cols| cols[0] = item.text.clone().into(),\n    );\n    injector.push(\n        TestItem {\n            text: \"blackberry\".into(),\n            category: 1,\n            priority: 50,\n        },\n        |item, cols| cols[0] = item.text.clone().into(),\n    );\n\n    // Search without scorer - results sorted by fuzzy score\n    nucleo.pattern.reparse(\n        0,\n        \"b\",\n        pattern::CaseMatching::Ignore,\n        pattern::Normalization::Smart,\n        false,\n    );\n    while nucleo.tick(10).running {}\n    assert_eq!(nucleo.snapshot().matched_item_count(), 3);\n\n    // Set scorer that uses priority as the score (ignoring fuzzy score)\n    nucleo.set_scorer(Some(Arc::new(|item: &TestItem, _fuzzy_score| {\n        item.priority\n    })));\n\n    // Search again - should be sorted by priority (high to low)\n    while nucleo.tick(10).running {}\n    let items: Vec<_> = nucleo\n        .snapshot()\n        .matched_items(..)\n        .map(|i| i.data.clone())\n        .collect();\n    assert_eq!(items.len(), 3);\n    assert_eq!(items[0].text, \"blueberry\"); // priority 100\n    assert_eq!(items[1].text, \"blackberry\"); // priority 50\n    assert_eq!(items[2].text, \"banana\"); // priority 10\n\n    // Verify external_score is set correctly\n    let matches = nucleo.snapshot().matches();\n    assert_eq!(matches[0].external_score, 100);\n    assert_eq!(matches[1].external_score, 50);\n    assert_eq!(matches[2].external_score, 10);\n}\n\n#[test]\nfn filter_and_scorer_combined() {\n    let mut nucleo: Nucleo<TestItem> = Nucleo::new(Config::DEFAULT, Arc::new(|| ()), Some(1), 1);\n    let injector = nucleo.injector();\n\n    injector.push(\n        TestItem {\n            text: \"cherry\".into(),\n            category: 1,\n            priority: 10,\n        },\n        |item, cols| cols[0] = item.text.clone().into(),\n    );\n    injector.push(\n        TestItem {\n            text: \"cranberry\".into(),\n            category: 2,\n            priority: 100,\n        },\n        |item, cols| cols[0] = item.text.clone().into(),\n    );\n    injector.push(\n        TestItem {\n            text: \"coconut\".into(),\n            category: 1,\n            priority: 50,\n        },\n        |item, cols| cols[0] = item.text.clone().into(),\n    );\n\n    // Set both filter (category 1) and scorer (priority)\n    nucleo.set_filter(Some(Arc::new(|item: &TestItem| item.category == 1)));\n    nucleo.set_scorer(Some(Arc::new(|item: &TestItem, _| item.priority)));\n\n    nucleo.pattern.reparse(\n        0,\n        \"c\",\n        pattern::CaseMatching::Ignore,\n        pattern::Normalization::Smart,\n        false,\n    );\n    while nucleo.tick(10).running {}\n\n    // Should have 2 items (cherry, coconut) sorted by priority\n    let items: Vec<_> = nucleo\n        .snapshot()\n        .matched_items(..)\n        .map(|i| i.data.clone())\n        .collect();\n    assert_eq!(items.len(), 2);\n    assert_eq!(items[0].text, \"coconut\"); // priority 50\n    assert_eq!(items[1].text, \"cherry\"); // priority 10\n}\n\n#[test]\nfn scorer_combines_with_fuzzy_score() {\n    let mut nucleo: Nucleo<TestItem> = Nucleo::new(Config::DEFAULT, Arc::new(|| ()), Some(1), 1);\n    let injector = nucleo.injector();\n\n    injector.push(\n        TestItem {\n            text: \"date\".into(),\n            category: 1,\n            priority: 100,\n        },\n        |item, cols| cols[0] = item.text.clone().into(),\n    );\n    injector.push(\n        TestItem {\n            text: \"dragon fruit\".into(),\n            category: 1,\n            priority: 10,\n        },\n        |item, cols| cols[0] = item.text.clone().into(),\n    );\n\n    // Set scorer that combines fuzzy score with priority\n    nucleo.set_scorer(Some(Arc::new(|item: &TestItem, fuzzy_score| {\n        fuzzy_score + item.priority\n    })));\n\n    nucleo.pattern.reparse(\n        0,\n        \"d\",\n        pattern::CaseMatching::Ignore,\n        pattern::Normalization::Smart,\n        false,\n    );\n    while nucleo.tick(10).running {}\n\n    // Both items match, verify that external_score includes priority boost\n    let matches = nucleo.snapshot().matches();\n    assert_eq!(matches.len(), 2);\n\n    // The raw fuzzy scores should be in Match.score\n    // The combined scores should be in Match.external_score\n    for m in matches {\n        let item = nucleo.snapshot().get_item(m.idx).unwrap();\n        assert_eq!(m.external_score, m.score + item.data.priority);\n    }\n}\n"
  },
  {
    "path": "crates/atuin-nucleo/src/worker.rs",
    "content": "use std::cell::UnsafeCell;\nuse std::mem::take;\nuse std::sync::atomic::{self, AtomicBool, AtomicU32};\nuse std::sync::Arc;\n\nuse atuin_nucleo_matcher::Config;\nuse parking_lot::Mutex;\nuse rayon::{prelude::*, ThreadPool};\n\nuse crate::par_sort::par_quicksort;\nuse crate::pattern::{self, MultiPattern};\nuse crate::{boxcar, Filter, Match, Scorer};\n\nstruct Matchers(Box<[UnsafeCell<atuin_nucleo_matcher::Matcher>]>);\n\nimpl Matchers {\n    // this is not a true mut from ref, we use a cell here\n    #[allow(clippy::mut_from_ref)]\n    unsafe fn get(&self) -> &mut atuin_nucleo_matcher::Matcher {\n        &mut *self.0[rayon::current_thread_index().unwrap()].get()\n    }\n}\n\nunsafe impl Sync for Matchers {}\nunsafe impl Send for Matchers {}\n\npub(crate) struct Worker<T: Sync + Send + 'static> {\n    pub(crate) running: bool,\n    matchers: Matchers,\n    pub(crate) matches: Vec<Match>,\n    pub(crate) pattern: MultiPattern,\n    pub(crate) sort_results: bool,\n    pub(crate) reverse_items: bool,\n    pub(crate) canceled: Arc<AtomicBool>,\n    pub(crate) should_notify: Arc<AtomicBool>,\n    pub(crate) was_canceled: bool,\n    pub(crate) last_snapshot: u32,\n    notify: Arc<dyn Fn() + Sync + Send>,\n    pub(crate) items: Arc<boxcar::Vec<T>>,\n    in_flight: Vec<u32>,\n    pub(crate) filter: Option<Filter<T>>,\n    pub(crate) scorer: Option<Scorer<T>>,\n}\n\nimpl<T: Sync + Send + 'static> Worker<T> {\n    pub(crate) fn item_count(&self) -> u32 {\n        self.last_snapshot - self.in_flight.len() as u32\n    }\n    pub(crate) fn update_config(&mut self, config: Config) {\n        for matcher in self.matchers.0.iter_mut() {\n            matcher.get_mut().config = config.clone();\n        }\n    }\n    pub(crate) fn sort_results(&mut self, sort_results: bool) {\n        self.sort_results = sort_results;\n    }\n    pub(crate) fn reverse_items(&mut self, reverse_items: bool) {\n        self.reverse_items = reverse_items;\n    }\n\n    pub(crate) fn set_filter(&mut self, filter: Option<Filter<T>>) {\n        self.filter = filter;\n    }\n\n    pub(crate) fn set_scorer(&mut self, scorer: Option<Scorer<T>>) {\n        self.scorer = scorer;\n    }\n\n    pub(crate) fn new(\n        worker_threads: Option<usize>,\n        config: Config,\n        notify: Arc<dyn Fn() + Sync + Send>,\n        cols: u32,\n    ) -> (ThreadPool, Self) {\n        let worker_threads = worker_threads\n            .unwrap_or_else(|| std::thread::available_parallelism().map_or(4, |it| it.get()));\n        let pool = rayon::ThreadPoolBuilder::new()\n            .thread_name(|i| format!(\"nucleo worker {i}\"))\n            .num_threads(worker_threads)\n            .build()\n            .expect(\"creating threadpool failed\");\n        let matchers = (0..worker_threads)\n            .map(|_| UnsafeCell::new(atuin_nucleo_matcher::Matcher::new(config.clone())))\n            .collect();\n        let worker = Worker {\n            running: false,\n            matchers: Matchers(matchers),\n            last_snapshot: 0,\n            matches: Vec::new(),\n            // just a placeholder\n            pattern: MultiPattern::new(cols as usize),\n            sort_results: true,\n            reverse_items: false,\n            canceled: Arc::new(AtomicBool::new(false)),\n            should_notify: Arc::new(AtomicBool::new(false)),\n            was_canceled: false,\n            notify,\n            items: Arc::new(boxcar::Vec::with_capacity(2 * 1024, cols)),\n            in_flight: Vec::with_capacity(64),\n            filter: None,\n            scorer: None,\n        };\n        (pool, worker)\n    }\n\n    unsafe fn process_new_items(&mut self, unmatched: &AtomicU32) {\n        let matchers = &self.matchers;\n        let pattern = &self.pattern;\n        self.matches.reserve(self.in_flight.len());\n        self.in_flight.retain(|&idx| {\n            let Some(item) = self.items.get(idx) else {\n                return true;\n            };\n            // Apply filter if set\n            if let Some(ref filter) = self.filter {\n                if !filter(item.data) {\n                    return false; // Item is ready but filtered out\n                }\n            }\n            if let Some(score) = pattern.score(item.matcher_columns, matchers.get()) {\n                let external_score = match &self.scorer {\n                    Some(scorer) => scorer(item.data, score),\n                    None => score,\n                };\n                self.matches.push(Match {\n                    score,\n                    external_score,\n                    idx,\n                });\n            };\n            false\n        });\n        let new_snapshot = self.items.par_snapshot(self.last_snapshot);\n        if new_snapshot.end() != self.last_snapshot {\n            let end = new_snapshot.end();\n            let in_flight = Mutex::new(&mut self.in_flight);\n            let items = new_snapshot.map(|(idx, item)| {\n                let Some(item) = item else {\n                    in_flight.lock().push(idx);\n                    unmatched.fetch_add(1, atomic::Ordering::Relaxed);\n                    return Match {\n                        score: 0,\n                        external_score: 0,\n                        idx: u32::MAX,\n                    };\n                };\n                if self.canceled.load(atomic::Ordering::Relaxed) {\n                    return Match {\n                        score: 0,\n                        external_score: 0,\n                        idx,\n                    };\n                }\n                // Apply filter if set\n                if let Some(ref filter) = self.filter {\n                    if !filter(item.data) {\n                        unmatched.fetch_add(1, atomic::Ordering::Relaxed);\n                        return Match {\n                            score: 0,\n                            external_score: 0,\n                            idx: u32::MAX,\n                        };\n                    }\n                }\n                let Some(score) = pattern.score(item.matcher_columns, matchers.get()) else {\n                    unmatched.fetch_add(1, atomic::Ordering::Relaxed);\n                    return Match {\n                        score: 0,\n                        external_score: 0,\n                        idx: u32::MAX,\n                    };\n                };\n                let external_score = match &self.scorer {\n                    Some(scorer) => scorer(item.data, score),\n                    None => score,\n                };\n                Match {\n                    score,\n                    external_score,\n                    idx,\n                }\n            });\n            self.matches.par_extend(items);\n            self.last_snapshot = end;\n        }\n    }\n\n    fn remove_in_flight_matches(&mut self) {\n        let mut off = 0;\n        self.in_flight.retain(|&i| {\n            let is_in_flight = self.items.get(i).is_none();\n            if is_in_flight {\n                self.matches.remove((i - off) as usize);\n                off += 1;\n            }\n            is_in_flight\n        });\n    }\n\n    unsafe fn process_new_items_trivial(&mut self) {\n        let new_snapshot = self.items.snapshot(self.last_snapshot);\n        if new_snapshot.end() != self.last_snapshot {\n            let end = new_snapshot.end();\n            let items = new_snapshot.filter_map(|(idx, item)| {\n                let item = item?;\n                // Apply filter if set\n                if let Some(ref filter) = self.filter {\n                    if !filter(item.data) {\n                        return None;\n                    }\n                }\n                // For empty pattern, apply scorer with score=0 if set\n                let external_score = match &self.scorer {\n                    Some(scorer) => scorer(item.data, 0),\n                    None => 0,\n                };\n                Some(Match {\n                    score: 0,\n                    external_score,\n                    idx,\n                })\n            });\n            self.matches.extend(items);\n            self.last_snapshot = end;\n        }\n    }\n\n    pub(crate) unsafe fn run(&mut self, pattern_status: pattern::Status, cleared: bool) {\n        self.running = true;\n        self.was_canceled = false;\n\n        if cleared {\n            self.last_snapshot = 0;\n            self.in_flight.clear();\n            self.matches.clear();\n        }\n\n        // TODO: be smarter around reusing past results for rescoring\n        if self.pattern.is_empty() {\n            self.reset_matches();\n            self.process_new_items_trivial();\n            let canceled = self.sort_matches();\n            if canceled {\n                self.was_canceled = true;\n            } else if self.should_notify.load(atomic::Ordering::Relaxed) {\n                (self.notify)();\n            }\n            return;\n        }\n\n        if pattern_status == pattern::Status::Rescore {\n            self.reset_matches();\n        }\n\n        let mut unmatched = AtomicU32::new(0);\n        if pattern_status != pattern::Status::Unchanged && !self.matches.is_empty() {\n            self.process_new_items_trivial();\n            let matchers = &self.matchers;\n            let pattern = &self.pattern;\n            self.matches\n                .par_iter_mut()\n                .take_any_while(|_| !self.canceled.load(atomic::Ordering::Relaxed))\n                .for_each(|match_| {\n                    if match_.idx == u32::MAX {\n                        debug_assert_eq!(match_.score, 0);\n                        unmatched.fetch_add(1, atomic::Ordering::Relaxed);\n                        return;\n                    }\n                    // safety: in-flight items are never added to the matches\n                    let item = self.items.get_unchecked(match_.idx);\n                    // Apply filter if set\n                    if let Some(ref filter) = self.filter {\n                        if !filter(item.data) {\n                            unmatched.fetch_add(1, atomic::Ordering::Relaxed);\n                            match_.score = 0;\n                            match_.external_score = 0;\n                            match_.idx = u32::MAX;\n                            return;\n                        }\n                    }\n                    if let Some(score) = pattern.score(item.matcher_columns, matchers.get()) {\n                        match_.score = score;\n                        match_.external_score = match &self.scorer {\n                            Some(scorer) => scorer(item.data, score),\n                            None => score,\n                        };\n                    } else {\n                        unmatched.fetch_add(1, atomic::Ordering::Relaxed);\n                        match_.score = 0;\n                        match_.external_score = 0;\n                        match_.idx = u32::MAX;\n                    }\n                });\n        } else {\n            self.process_new_items(&unmatched);\n        }\n\n        let canceled = self.sort_matches();\n        if canceled {\n            self.was_canceled = true;\n        } else {\n            self.matches\n                .truncate(self.matches.len() - take(unmatched.get_mut()) as usize);\n            if self.should_notify.load(atomic::Ordering::Relaxed) {\n                (self.notify)();\n            }\n        }\n    }\n\n    unsafe fn sort_matches(&mut self) -> bool {\n        if self.sort_results {\n            par_quicksort(\n                &mut self.matches,\n                |match1, match2| {\n                    // Primary sort: external_score (used for frecency/custom ranking)\n                    if match1.external_score != match2.external_score {\n                        return match1.external_score > match2.external_score;\n                    }\n                    if match1.idx == u32::MAX {\n                        return false;\n                    }\n                    if match2.idx == u32::MAX {\n                        return true;\n                    }\n                    // the tie breaker is comparatively rarely needed so we keep it\n                    // in a branch especially because we need to access the items\n                    // array here which involves some pointer chasing\n                    let item1 = self.items.get_unchecked(match1.idx);\n                    let item2 = &self.items.get_unchecked(match2.idx);\n                    let len1: u32 = item1\n                        .matcher_columns\n                        .iter()\n                        .map(|haystack| haystack.len() as u32)\n                        .sum();\n                    let len2 = item2\n                        .matcher_columns\n                        .iter()\n                        .map(|haystack| haystack.len() as u32)\n                        .sum();\n                    if len1 == len2 {\n                        if self.reverse_items {\n                            match2.idx < match1.idx\n                        } else {\n                            match1.idx < match2.idx\n                        }\n                    } else {\n                        len1 < len2\n                    }\n                },\n                &self.canceled,\n            )\n        } else {\n            par_quicksort(\n                &mut self.matches,\n                |match1, match2| {\n                    if match1.idx == u32::MAX {\n                        return false;\n                    }\n                    if match2.idx == u32::MAX {\n                        return true;\n                    }\n                    if self.reverse_items {\n                        match2.idx < match1.idx\n                    } else {\n                        match1.idx < match2.idx\n                    }\n                },\n                &self.canceled,\n            )\n        }\n    }\n\n    fn reset_matches(&mut self) {\n        self.matches.clear();\n        // When resetting, apply filter if set\n        if let Some(ref filter) = self.filter {\n            for idx in 0..self.last_snapshot {\n                // Items up to last_snapshot should be initialized\n                if let Some(item) = self.items.get(idx) {\n                    if filter(item.data) {\n                        let external_score = match &self.scorer {\n                            Some(scorer) => scorer(item.data, 0),\n                            None => 0,\n                        };\n                        self.matches.push(Match {\n                            score: 0,\n                            external_score,\n                            idx,\n                        });\n                    }\n                }\n            }\n        } else {\n            // No filter - add all items\n            self.matches\n                .extend((0..self.last_snapshot).map(|idx| Match {\n                    score: 0,\n                    external_score: 0,\n                    idx,\n                }));\n        }\n        // there are usually only very few in flight items (one for each writer)\n        self.remove_in_flight_matches();\n    }\n}\n"
  },
  {
    "path": "crates/atuin-nucleo/tarpaulin.toml",
    "content": "exclude = [\"matcher/src/tests.rs\", \"matcher/src/debug.rs\", \"matcher/src/chars/normalize.rs\"]\n"
  },
  {
    "path": "crates/atuin-nucleo/typos.toml",
    "content": "default.extend-ignore-re = [\"\\\\\\\\u\\\\{[0-9A-Za-z]*\\\\}\"]\n[files]\nextend-exclude = [\"matcher/src/tests.rs\",\"src/pattern/tests.rs\", \"*.html\"]\n"
  },
  {
    "path": "crates/atuin-scripts/Cargo.toml",
    "content": "[package]\nname = \"atuin-scripts\"\nedition = \"2024\"\nversion = { workspace = true }\ndescription = \"The scripts crate for Atuin\"\n\nauthors.workspace = true\nrust-version.workspace = true\nlicense.workspace = true\nhomepage.workspace = true\nrepository.workspace = true\nreadme.workspace = true\n\n# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html\n\n[dependencies]\natuin-client = { path = \"../atuin-client\", version = \"18.13.3\" }\natuin-common = { path = \"../atuin-common\", version = \"18.13.3\" }\n\ntracing = { workspace = true }\ntracing-subscriber = { workspace = true }\nrmp = { version = \"0.8.14\" }\nuuid = { workspace = true }\neyre = { workspace = true }\ntokio = { workspace = true }\nserde = { workspace = true }\ntyped-builder = { workspace = true }\npretty_assertions = { workspace = true }\nsql-builder = { workspace = true }\nsqlx = { workspace = true }\ntempfile = { workspace = true }\nminijinja = { workspace = true }\nserde_json = { workspace = true }\n\n"
  },
  {
    "path": "crates/atuin-scripts/migrations/20250326160051_create_scripts.down.sql",
    "content": "DROP TABLE scripts;\nDROP TABLE script_tags;"
  },
  {
    "path": "crates/atuin-scripts/migrations/20250326160051_create_scripts.up.sql",
    "content": "-- Add up migration script here\nCREATE TABLE scripts (\n    id TEXT PRIMARY KEY,\n    name TEXT NOT NULL,\n    description TEXT NOT NULL,\n    shebang TEXT NOT NULL,\n    script TEXT NOT NULL,\n    inserted_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))\n);\n\nCREATE TABLE script_tags (\n    id INTEGER PRIMARY KEY,\n    script_id TEXT NOT NULL,\n    tag TEXT NOT NULL\n);\n\nCREATE UNIQUE INDEX idx_script_tags ON script_tags (script_id, tag);"
  },
  {
    "path": "crates/atuin-scripts/migrations/20250402170430_unique_names.down.sql",
    "content": "-- Add down migration script here\nalter table scripts drop index name_uniq_idx;"
  },
  {
    "path": "crates/atuin-scripts/migrations/20250402170430_unique_names.up.sql",
    "content": "-- Add up migration script here\ncreate unique index name_uniq_idx ON scripts(name);"
  },
  {
    "path": "crates/atuin-scripts/src/database.rs",
    "content": "use std::{path::Path, str::FromStr, time::Duration};\n\nuse atuin_common::utils;\nuse sqlx::{\n    Result, Row,\n    sqlite::{\n        SqliteConnectOptions, SqliteJournalMode, SqlitePool, SqlitePoolOptions, SqliteRow,\n        SqliteSynchronous,\n    },\n};\nuse tokio::fs;\nuse tracing::debug;\nuse uuid::Uuid;\n\nuse crate::store::script::Script;\n\n#[derive(Debug, Clone)]\npub struct Database {\n    pub pool: SqlitePool,\n}\n\nimpl Database {\n    pub async fn new(path: impl AsRef<Path>, timeout: f64) -> Result<Self> {\n        let path = path.as_ref();\n        debug!(\"opening script sqlite database at {:?}\", path);\n\n        if utils::broken_symlink(path) {\n            eprintln!(\n                \"Atuin: Script sqlite db path ({path:?}) is a broken symlink. Unable to read or create replacement.\"\n            );\n            std::process::exit(1);\n        }\n\n        if !path.exists()\n            && let Some(dir) = path.parent()\n        {\n            fs::create_dir_all(dir).await?;\n        }\n\n        let opts = SqliteConnectOptions::from_str(path.as_os_str().to_str().unwrap())?\n            .journal_mode(SqliteJournalMode::Wal)\n            .optimize_on_close(true, None)\n            .synchronous(SqliteSynchronous::Normal)\n            .with_regexp()\n            .foreign_keys(true)\n            .create_if_missing(true);\n\n        let pool = SqlitePoolOptions::new()\n            .acquire_timeout(Duration::from_secs_f64(timeout))\n            .connect_with(opts)\n            .await?;\n\n        Self::setup_db(&pool).await?;\n        Ok(Self { pool })\n    }\n\n    pub async fn sqlite_version(&self) -> Result<String> {\n        sqlx::query_scalar(\"SELECT sqlite_version()\")\n            .fetch_one(&self.pool)\n            .await\n    }\n\n    async fn setup_db(pool: &SqlitePool) -> Result<()> {\n        debug!(\"running sqlite database setup\");\n\n        sqlx::migrate!(\"./migrations\").run(pool).await?;\n\n        Ok(())\n    }\n\n    async fn save_raw(tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>, s: &Script) -> Result<()> {\n        sqlx::query(\n            \"insert or ignore into scripts(id, name, description, shebang, script)\n                values(?1, ?2, ?3, ?4, ?5)\",\n        )\n        .bind(s.id.to_string())\n        .bind(s.name.as_str())\n        .bind(s.description.as_str())\n        .bind(s.shebang.as_str())\n        .bind(s.script.as_str())\n        .execute(&mut **tx)\n        .await?;\n\n        for tag in s.tags.iter() {\n            sqlx::query(\n                \"insert or ignore into script_tags(script_id, tag)\n                values(?1, ?2)\",\n            )\n            .bind(s.id.to_string())\n            .bind(tag)\n            .execute(&mut **tx)\n            .await?;\n        }\n\n        Ok(())\n    }\n\n    pub async fn save(&self, s: &Script) -> Result<()> {\n        debug!(\"saving script to sqlite\");\n        let mut tx = self.pool.begin().await?;\n        Self::save_raw(&mut tx, s).await?;\n        tx.commit().await?;\n\n        Ok(())\n    }\n\n    pub async fn save_bulk(&self, s: &[Script]) -> Result<()> {\n        debug!(\"saving scripts to sqlite\");\n\n        let mut tx = self.pool.begin().await?;\n\n        for i in s {\n            Self::save_raw(&mut tx, i).await?;\n        }\n\n        tx.commit().await?;\n\n        Ok(())\n    }\n\n    fn query_script(row: SqliteRow) -> Script {\n        let id = row.get(\"id\");\n        let name = row.get(\"name\");\n        let description = row.get(\"description\");\n        let shebang = row.get(\"shebang\");\n        let script = row.get(\"script\");\n\n        let id = Uuid::parse_str(id).unwrap();\n\n        Script {\n            id,\n            name,\n            description,\n            shebang,\n            script,\n            tags: vec![],\n        }\n    }\n\n    fn query_script_tags(row: SqliteRow) -> String {\n        row.get(\"tag\")\n    }\n\n    #[allow(dead_code)]\n    async fn load(&self, id: &str) -> Result<Option<Script>> {\n        debug!(\"loading script item {}\", id);\n\n        let res = sqlx::query(\"select * from scripts where id = ?1\")\n            .bind(id)\n            .map(Self::query_script)\n            .fetch_optional(&self.pool)\n            .await?;\n\n        // intentionally not joining, don't want to duplicate the script data in memory a whole bunch.\n        if let Some(mut script) = res {\n            let tags = sqlx::query(\"select tag from script_tags where script_id = ?1\")\n                .bind(id)\n                .map(Self::query_script_tags)\n                .fetch_all(&self.pool)\n                .await?;\n\n            script.tags = tags;\n            Ok(Some(script))\n        } else {\n            Ok(None)\n        }\n    }\n\n    pub async fn list(&self) -> Result<Vec<Script>> {\n        debug!(\"listing scripts\");\n\n        let mut res = sqlx::query(\"select * from scripts\")\n            .map(Self::query_script)\n            .fetch_all(&self.pool)\n            .await?;\n\n        // Fetch all the tags for each script\n        for script in res.iter_mut() {\n            let tags = sqlx::query(\"select tag from script_tags where script_id = ?1\")\n                .bind(script.id.to_string())\n                .map(Self::query_script_tags)\n                .fetch_all(&self.pool)\n                .await?;\n\n            script.tags = tags;\n        }\n\n        Ok(res)\n    }\n\n    pub async fn clear(&self) -> Result<()> {\n        debug!(\"clearing all scripts from sqlite\");\n\n        sqlx::query(\"delete from script_tags\")\n            .execute(&self.pool)\n            .await?;\n        sqlx::query(\"delete from scripts\")\n            .execute(&self.pool)\n            .await?;\n\n        Ok(())\n    }\n\n    pub async fn delete(&self, id: &str) -> Result<()> {\n        debug!(\"deleting script {}\", id);\n\n        sqlx::query(\"delete from scripts where id = ?1\")\n            .bind(id)\n            .execute(&self.pool)\n            .await?;\n\n        // delete all the tags for the script\n        sqlx::query(\"delete from script_tags where script_id = ?1\")\n            .bind(id)\n            .execute(&self.pool)\n            .await?;\n\n        Ok(())\n    }\n\n    pub async fn update(&self, s: &Script) -> Result<()> {\n        debug!(\"updating script {:?}\", s);\n\n        let mut tx = self.pool.begin().await?;\n\n        // Update the script's base fields\n        sqlx::query(\"update scripts set name = ?1, description = ?2, shebang = ?3, script = ?4 where id = ?5\")\n            .bind(s.name.as_str())\n            .bind(s.description.as_str())\n            .bind(s.shebang.as_str())\n            .bind(s.script.as_str())\n            .bind(s.id.to_string())\n            .execute(&mut *tx)\n            .await?;\n\n        // Delete all existing tags for this script\n        sqlx::query(\"delete from script_tags where script_id = ?1\")\n            .bind(s.id.to_string())\n            .execute(&mut *tx)\n            .await?;\n\n        // Insert new tags\n        for tag in s.tags.iter() {\n            sqlx::query(\n                \"insert or ignore into script_tags(script_id, tag)\n                values(?1, ?2)\",\n            )\n            .bind(s.id.to_string())\n            .bind(tag)\n            .execute(&mut *tx)\n            .await?;\n        }\n\n        tx.commit().await?;\n\n        Ok(())\n    }\n\n    pub async fn get_by_name(&self, name: &str) -> Result<Option<Script>> {\n        let res = sqlx::query(\"select * from scripts where name = ?1\")\n            .bind(name)\n            .map(Self::query_script)\n            .fetch_optional(&self.pool)\n            .await?;\n\n        let script = if let Some(mut script) = res {\n            let tags = sqlx::query(\"select tag from script_tags where script_id = ?1\")\n                .bind(script.id.to_string())\n                .map(Self::query_script_tags)\n                .fetch_all(&self.pool)\n                .await?;\n\n            script.tags = tags;\n            Some(script)\n        } else {\n            None\n        };\n\n        Ok(script)\n    }\n}\n\n#[cfg(test)]\nmod test {\n    use super::*;\n\n    #[tokio::test]\n    async fn test_list() {\n        let db = Database::new(\"sqlite::memory:\", 1.0).await.unwrap();\n        let scripts = db.list().await.unwrap();\n        assert_eq!(scripts.len(), 0);\n\n        let script = Script::builder()\n            .name(\"test\".to_string())\n            .description(\"test\".to_string())\n            .shebang(\"test\".to_string())\n            .script(\"test\".to_string())\n            .build();\n\n        db.save(&script).await.unwrap();\n\n        let scripts = db.list().await.unwrap();\n        assert_eq!(scripts.len(), 1);\n        assert_eq!(scripts[0].name, \"test\");\n    }\n\n    #[tokio::test]\n    async fn test_save_load() {\n        let db = Database::new(\"sqlite::memory:\", 1.0).await.unwrap();\n\n        let script = Script::builder()\n            .name(\"test name\".to_string())\n            .description(\"test description\".to_string())\n            .shebang(\"test shebang\".to_string())\n            .script(\"test script\".to_string())\n            .build();\n\n        db.save(&script).await.unwrap();\n\n        let loaded = db.load(&script.id.to_string()).await.unwrap().unwrap();\n\n        assert_eq!(loaded, script);\n    }\n\n    #[tokio::test]\n    async fn test_save_bulk() {\n        let db = Database::new(\"sqlite::memory:\", 1.0).await.unwrap();\n\n        let scripts = vec![\n            Script::builder()\n                .name(\"test name\".to_string())\n                .description(\"test description\".to_string())\n                .shebang(\"test shebang\".to_string())\n                .script(\"test script\".to_string())\n                .build(),\n            Script::builder()\n                .name(\"test name 2\".to_string())\n                .description(\"test description 2\".to_string())\n                .shebang(\"test shebang 2\".to_string())\n                .script(\"test script 2\".to_string())\n                .build(),\n        ];\n\n        db.save_bulk(&scripts).await.unwrap();\n\n        let loaded = db.list().await.unwrap();\n        assert_eq!(loaded.len(), 2);\n        assert_eq!(loaded[0].name, \"test name\");\n        assert_eq!(loaded[1].name, \"test name 2\");\n    }\n\n    #[tokio::test]\n    async fn test_delete() {\n        let db = Database::new(\"sqlite::memory:\", 1.0).await.unwrap();\n\n        let script = Script::builder()\n            .name(\"test name\".to_string())\n            .description(\"test description\".to_string())\n            .shebang(\"test shebang\".to_string())\n            .script(\"test script\".to_string())\n            .build();\n\n        db.save(&script).await.unwrap();\n\n        assert_eq!(db.list().await.unwrap().len(), 1);\n        db.delete(&script.id.to_string()).await.unwrap();\n\n        let loaded = db.list().await.unwrap();\n        assert_eq!(loaded.len(), 0);\n    }\n}\n"
  },
  {
    "path": "crates/atuin-scripts/src/execution.rs",
    "content": "use crate::store::script::Script;\nuse eyre::Result;\nuse std::collections::{HashMap, HashSet};\nuse std::process::Stdio;\nuse tempfile::NamedTempFile;\nuse tokio::io::{AsyncReadExt, AsyncWriteExt, BufReader};\nuse tokio::sync::mpsc;\nuse tokio::task;\nuse tracing::debug;\n\n// Helper function to build a complete script with shebang\npub fn build_executable_script(script: String, shebang: String) -> String {\n    if shebang.is_empty() {\n        // Default to bash if no shebang is provided\n        format!(\"#!/usr/bin/env bash\\n{script}\")\n    } else if script.starts_with(\"#!\") {\n        format!(\"{shebang}\\n{script}\")\n    } else {\n        format!(\"#!{shebang}\\n{script}\")\n    }\n}\n\n/// Represents the communication channels for an interactive script\npub struct ScriptSession {\n    /// Channel to send input to the script\n    pub stdin_tx: mpsc::Sender<String>,\n    /// Exit code of the process once it completes\n    pub exit_code_rx: mpsc::Receiver<i32>,\n}\n\nimpl ScriptSession {\n    /// Send input to the running script\n    pub async fn send_input(&self, input: String) -> Result<(), mpsc::error::SendError<String>> {\n        self.stdin_tx.send(input).await\n    }\n\n    /// Wait for the script to complete and get the exit code\n    pub async fn wait_for_exit(&mut self) -> Option<i32> {\n        self.exit_code_rx.recv().await\n    }\n}\n\nfn setup_template(script: &Script) -> Result<minijinja::Environment<'_>> {\n    let mut env = minijinja::Environment::new();\n    env.set_trim_blocks(true);\n    env.add_template(\"script\", script.script.as_str())?;\n\n    Ok(env)\n}\n\n/// Template a script with the given context\npub fn template_script(\n    script: &Script,\n    context: &HashMap<String, serde_json::Value>,\n) -> Result<String> {\n    let env = setup_template(script)?;\n    let template = env.get_template(\"script\")?;\n    let rendered = template.render(context)?;\n\n    Ok(rendered)\n}\n\n/// Get the variables that need to be templated in a script\npub fn template_variables(script: &Script) -> Result<HashSet<String>> {\n    let env = setup_template(script)?;\n    let template = env.get_template(\"script\")?;\n\n    Ok(template.undeclared_variables(true))\n}\n\n/// Execute a script interactively, allowing for ongoing stdin/stdout interaction\npub async fn execute_script_interactive(\n    script: String,\n    shebang: String,\n) -> Result<ScriptSession, Box<dyn std::error::Error + Send + Sync>> {\n    // Create a temporary file for the script\n    let temp_file = NamedTempFile::new()?;\n    let temp_path = temp_file.path().to_path_buf();\n\n    debug!(\"creating temp file at {}\", temp_path.display());\n\n    // Extract interpreter from shebang for fallback execution\n    let interpreter = if !shebang.is_empty() {\n        shebang.trim_start_matches(\"#!\").trim().to_string()\n    } else {\n        \"/usr/bin/env bash\".to_string()\n    };\n\n    // Write script content to the temp file, including the shebang\n    let full_script_content = build_executable_script(script.clone(), shebang.clone());\n\n    debug!(\"writing script content to temp file\");\n    tokio::fs::write(&temp_path, &full_script_content).await?;\n\n    // Make it executable on Unix systems\n    #[cfg(unix)]\n    {\n        debug!(\"making script executable\");\n        use std::os::unix::fs::PermissionsExt;\n        let mut perms = std::fs::metadata(&temp_path)?.permissions();\n        perms.set_mode(0o755);\n        std::fs::set_permissions(&temp_path, perms)?;\n    }\n\n    // Store the temp_file to prevent it from being dropped\n    // This ensures it won't be deleted while the script is running\n    let _keep_temp_file = temp_file;\n\n    debug!(\"attempting direct script execution\");\n    let mut child_result = tokio::process::Command::new(temp_path.to_str().unwrap())\n        .stdin(Stdio::piped())\n        .stdout(Stdio::piped())\n        .stderr(Stdio::piped())\n        .spawn();\n\n    // If direct execution fails, try using the interpreter\n    if let Err(e) = &child_result {\n        debug!(\"direct execution failed: {}, trying with interpreter\", e);\n\n        // When falling back to interpreter, remove the shebang from the file\n        // Some interpreters don't handle scripts with shebangs well\n        debug!(\"writing script content without shebang for interpreter execution\");\n        tokio::fs::write(&temp_path, &script).await?;\n\n        // Parse the interpreter command\n        let parts: Vec<&str> = interpreter.split_whitespace().collect();\n        if !parts.is_empty() {\n            let mut cmd = tokio::process::Command::new(parts[0]);\n\n            // Add any interpreter args\n            for i in parts.iter().skip(1) {\n                cmd.arg(i);\n            }\n\n            // Add the script path\n            cmd.arg(temp_path.to_str().unwrap());\n\n            // Try with the interpreter\n            child_result = cmd\n                .stdin(Stdio::piped())\n                .stdout(Stdio::piped())\n                .stderr(Stdio::piped())\n                .spawn();\n        }\n    }\n\n    // If it still fails, return the error\n    let mut child = match child_result {\n        Ok(child) => child,\n        Err(e) => {\n            return Err(format!(\"Failed to execute script: {e}\").into());\n        }\n    };\n\n    // Get handles to stdin, stdout, stderr\n    let mut stdin = child\n        .stdin\n        .take()\n        .ok_or_else(|| \"Failed to open child process stdin\".to_string())?;\n    let stdout = child\n        .stdout\n        .take()\n        .ok_or_else(|| \"Failed to open child process stdout\".to_string())?;\n    let stderr = child\n        .stderr\n        .take()\n        .ok_or_else(|| \"Failed to open child process stderr\".to_string())?;\n\n    // Create channels for the interactive session\n    let (stdin_tx, mut stdin_rx) = mpsc::channel::<String>(32);\n    let (exit_code_tx, exit_code_rx) = mpsc::channel::<i32>(1);\n\n    // handle user stdin\n    debug!(\"spawning stdin handler\");\n    tokio::spawn(async move {\n        while let Some(input) = stdin_rx.recv().await {\n            if let Err(e) = stdin.write_all(input.as_bytes()).await {\n                eprintln!(\"Error writing to stdin: {e}\");\n                break;\n            }\n            if let Err(e) = stdin.flush().await {\n                eprintln!(\"Error flushing stdin: {e}\");\n                break;\n            }\n        }\n        // when the channel closes (sender dropped), we let stdin close naturally\n    });\n\n    // handle stdout\n    debug!(\"spawning stdout handler\");\n    let stdout_handle = task::spawn(async move {\n        let mut stdout_reader = BufReader::new(stdout);\n        let mut buffer = [0u8; 1024];\n        let mut stdout_writer = tokio::io::stdout();\n\n        loop {\n            match stdout_reader.read(&mut buffer).await {\n                Ok(0) => break, // End of stdout\n                Ok(n) => {\n                    if let Err(e) = stdout_writer.write_all(&buffer[0..n]).await {\n                        eprintln!(\"Error writing to stdout: {e}\");\n                        break;\n                    }\n                    if let Err(e) = stdout_writer.flush().await {\n                        eprintln!(\"Error flushing stdout: {e}\");\n                        break;\n                    }\n                }\n                Err(e) => {\n                    eprintln!(\"Error reading from process stdout: {e}\");\n                    break;\n                }\n            }\n        }\n    });\n\n    // Process stderr in a separate task\n    debug!(\"spawning stderr handler\");\n    let stderr_handle = task::spawn(async move {\n        let mut stderr_reader = BufReader::new(stderr);\n        let mut buffer = [0u8; 1024];\n        let mut stderr_writer = tokio::io::stderr();\n\n        loop {\n            match stderr_reader.read(&mut buffer).await {\n                Ok(0) => break, // End of stderr\n                Ok(n) => {\n                    if let Err(e) = stderr_writer.write_all(&buffer[0..n]).await {\n                        eprintln!(\"Error writing to stderr: {e}\");\n                        break;\n                    }\n                    if let Err(e) = stderr_writer.flush().await {\n                        eprintln!(\"Error flushing stderr: {e}\");\n                        break;\n                    }\n                }\n                Err(e) => {\n                    eprintln!(\"Error reading from process stderr: {e}\");\n                    break;\n                }\n            }\n        }\n    });\n\n    // Spawn a task to wait for the child process to complete\n    debug!(\"spawning exit code handler\");\n    let _keep_temp_file_clone = _keep_temp_file;\n    tokio::spawn(async move {\n        // Keep the temp file alive until the process completes\n        let _temp_file_ref = _keep_temp_file_clone;\n\n        // Wait for the child process to complete\n        let status = match child.wait().await {\n            Ok(status) => {\n                debug!(\"Process exited with status: {:?}\", status);\n                status\n            }\n            Err(e) => {\n                eprintln!(\"Error waiting for child process: {e}\");\n                // Send a default error code\n                let _ = exit_code_tx.send(-1).await;\n                return;\n            }\n        };\n\n        // Wait for stdout/stderr tasks to complete\n        if let Err(e) = stdout_handle.await {\n            eprintln!(\"Error joining stdout task: {e}\");\n        }\n\n        if let Err(e) = stderr_handle.await {\n            eprintln!(\"Error joining stderr task: {e}\");\n        }\n\n        // Send the exit code\n        let exit_code = status.code().unwrap_or(-1);\n        debug!(\"Sending exit code: {}\", exit_code);\n        let _ = exit_code_tx.send(exit_code).await;\n    });\n\n    // Return the communication channels as a ScriptSession\n    Ok(ScriptSession {\n        stdin_tx,\n        exit_code_rx,\n    })\n}\n"
  },
  {
    "path": "crates/atuin-scripts/src/lib.rs",
    "content": "pub mod database;\npub mod execution;\npub mod settings;\npub mod store;\n"
  },
  {
    "path": "crates/atuin-scripts/src/settings.rs",
    "content": "\n"
  },
  {
    "path": "crates/atuin-scripts/src/store/record.rs",
    "content": "use atuin_common::record::DecryptedData;\nuse eyre::{Result, eyre};\nuse uuid::Uuid;\n\nuse crate::store::script::SCRIPT_VERSION;\n\nuse super::script::Script;\n\n#[derive(Debug, Clone, PartialEq, Eq)]\npub enum ScriptRecord {\n    Create(Script),\n    Update(Script),\n    Delete(Uuid),\n}\n\nimpl ScriptRecord {\n    pub fn serialize(&self) -> Result<DecryptedData> {\n        use rmp::encode;\n\n        let mut output = vec![];\n\n        match self {\n            ScriptRecord::Create(script) => {\n                // 0 -> a script create\n                encode::write_u8(&mut output, 0)?;\n\n                let bytes = script.serialize()?;\n\n                encode::write_bin(&mut output, &bytes.0)?;\n            }\n\n            ScriptRecord::Delete(id) => {\n                // 1 -> a script delete\n                encode::write_u8(&mut output, 1)?;\n                encode::write_str(&mut output, id.to_string().as_str())?;\n            }\n\n            ScriptRecord::Update(script) => {\n                // 2 -> a script update\n                encode::write_u8(&mut output, 2)?;\n                let bytes = script.serialize()?;\n                encode::write_bin(&mut output, &bytes.0)?;\n            }\n        };\n\n        Ok(DecryptedData(output))\n    }\n\n    pub fn deserialize(data: &DecryptedData, version: &str) -> Result<Self> {\n        use rmp::decode;\n\n        fn error_report<E: std::fmt::Debug>(err: E) -> eyre::Report {\n            eyre!(\"{err:?}\")\n        }\n\n        match version {\n            SCRIPT_VERSION => {\n                let mut bytes = decode::Bytes::new(&data.0);\n\n                let record_type = decode::read_u8(&mut bytes).map_err(error_report)?;\n\n                match record_type {\n                    // create\n                    0 => {\n                        // written by encode::write_bin above\n                        let _ = decode::read_bin_len(&mut bytes).map_err(error_report)?;\n                        let script = Script::deserialize(bytes.remaining_slice())?;\n                        Ok(ScriptRecord::Create(script))\n                    }\n\n                    // delete\n                    1 => {\n                        let bytes = bytes.remaining_slice();\n                        let (id, _) = decode::read_str_from_slice(bytes).map_err(error_report)?;\n                        Ok(ScriptRecord::Delete(Uuid::parse_str(id)?))\n                    }\n\n                    // update\n                    2 => {\n                        // written by encode::write_bin above\n                        let _ = decode::read_bin_len(&mut bytes).map_err(error_report)?;\n                        let script = Script::deserialize(bytes.remaining_slice())?;\n                        Ok(ScriptRecord::Update(script))\n                    }\n\n                    _ => Err(eyre!(\"unknown script record type {record_type}\")),\n                }\n            }\n            _ => Err(eyre!(\"unknown version {version:?}\")),\n        }\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn test_serialize_create() {\n        let script = Script::builder()\n            .id(uuid::Uuid::parse_str(\"0195c825a35f7982bdb016168881cbc6\").unwrap())\n            .name(\"test\".to_string())\n            .description(\"test\".to_string())\n            .shebang(\"test\".to_string())\n            .tags(vec![\"test\".to_string()])\n            .script(\"test\".to_string())\n            .build();\n\n        let record = ScriptRecord::Create(script);\n\n        let serialized = record.serialize().unwrap();\n\n        assert_eq!(\n            serialized.0,\n            vec![\n                204, 0, 196, 65, 150, 217, 36, 48, 49, 57, 53, 99, 56, 50, 53, 45, 97, 51, 53, 102,\n                45, 55, 57, 56, 50, 45, 98, 100, 98, 48, 45, 49, 54, 49, 54, 56, 56, 56, 49, 99,\n                98, 99, 54, 164, 116, 101, 115, 116, 164, 116, 101, 115, 116, 164, 116, 101, 115,\n                116, 145, 164, 116, 101, 115, 116, 164, 116, 101, 115, 116\n            ]\n        );\n    }\n\n    #[test]\n    fn test_serialize_delete() {\n        let record = ScriptRecord::Delete(\n            uuid::Uuid::parse_str(\"0195c825a35f7982bdb016168881cbc6\").unwrap(),\n        );\n\n        let serialized = record.serialize().unwrap();\n\n        assert_eq!(\n            serialized.0,\n            vec![\n                204, 1, 217, 36, 48, 49, 57, 53, 99, 56, 50, 53, 45, 97, 51, 53, 102, 45, 55, 57,\n                56, 50, 45, 98, 100, 98, 48, 45, 49, 54, 49, 54, 56, 56, 56, 49, 99, 98, 99, 54\n            ]\n        );\n    }\n\n    #[test]\n    fn test_serialize_update() {\n        let script = Script::builder()\n            .id(uuid::Uuid::parse_str(\"0195c825a35f7982bdb016168881cbc6\").unwrap())\n            .name(String::from(\"test\"))\n            .description(String::from(\"test\"))\n            .shebang(String::from(\"test\"))\n            .tags(vec![String::from(\"test\"), String::from(\"test2\")])\n            .script(String::from(\"test\"))\n            .build();\n\n        let record = ScriptRecord::Update(script);\n\n        let serialized = record.serialize().unwrap();\n\n        assert_eq!(\n            serialized.0,\n            vec![\n                204, 2, 196, 71, 150, 217, 36, 48, 49, 57, 53, 99, 56, 50, 53, 45, 97, 51, 53, 102,\n                45, 55, 57, 56, 50, 45, 98, 100, 98, 48, 45, 49, 54, 49, 54, 56, 56, 56, 49, 99,\n                98, 99, 54, 164, 116, 101, 115, 116, 164, 116, 101, 115, 116, 164, 116, 101, 115,\n                116, 146, 164, 116, 101, 115, 116, 165, 116, 101, 115, 116, 50, 164, 116, 101, 115,\n                116\n            ],\n        );\n    }\n\n    #[test]\n    fn test_serialize_deserialize_create() {\n        let script = Script::builder()\n            .name(\"test\".to_string())\n            .description(\"test\".to_string())\n            .shebang(\"test\".to_string())\n            .tags(vec![\"test\".to_string()])\n            .script(\"test\".to_string())\n            .build();\n\n        let record = ScriptRecord::Create(script);\n\n        let serialized = record.serialize().unwrap();\n        let deserialized = ScriptRecord::deserialize(&serialized, SCRIPT_VERSION).unwrap();\n\n        assert_eq!(record, deserialized);\n    }\n\n    #[test]\n    fn test_serialize_deserialize_delete() {\n        let record = ScriptRecord::Delete(\n            uuid::Uuid::parse_str(\"0195c825a35f7982bdb016168881cbc6\").unwrap(),\n        );\n\n        let serialized = record.serialize().unwrap();\n        let deserialized = ScriptRecord::deserialize(&serialized, SCRIPT_VERSION).unwrap();\n\n        assert_eq!(record, deserialized);\n    }\n\n    #[test]\n    fn test_serialize_deserialize_update() {\n        let script = Script::builder()\n            .name(\"test\".to_string())\n            .description(\"test\".to_string())\n            .shebang(\"test\".to_string())\n            .tags(vec![\"test\".to_string()])\n            .script(\"test\".to_string())\n            .build();\n\n        let record = ScriptRecord::Update(script);\n\n        let serialized = record.serialize().unwrap();\n        let deserialized = ScriptRecord::deserialize(&serialized, SCRIPT_VERSION).unwrap();\n\n        assert_eq!(record, deserialized);\n    }\n}\n"
  },
  {
    "path": "crates/atuin-scripts/src/store/script.rs",
    "content": "use atuin_common::record::DecryptedData;\nuse eyre::{Result, bail, ensure};\nuse uuid::Uuid;\n\nuse rmp::{\n    decode::{self, Bytes},\n    encode,\n};\nuse typed_builder::TypedBuilder;\n\npub const SCRIPT_VERSION: &str = \"v0\";\npub const SCRIPT_TAG: &str = \"script\";\npub const SCRIPT_LEN: usize = 20000; // 20kb max total len\n\n#[derive(Debug, Clone, PartialEq, Eq, TypedBuilder)]\n/// A script is a set of commands that can be run, with the specified shebang\npub struct Script {\n    /// The id of the script\n    #[builder(default = uuid::Uuid::new_v4())]\n    pub id: Uuid,\n\n    /// The name of the script\n    pub name: String,\n\n    /// The description of the script\n    #[builder(default = String::new())]\n    pub description: String,\n\n    /// The interpreter of the script\n    #[builder(default = String::new())]\n    pub shebang: String,\n\n    /// The tags of the script\n    #[builder(default = Vec::new())]\n    pub tags: Vec<String>,\n\n    /// The script content\n    pub script: String,\n}\n\nimpl Script {\n    pub fn serialize(&self) -> Result<DecryptedData> {\n        // sort the tags first, to ensure consistent ordering\n        let mut tags = self.tags.clone();\n        tags.sort();\n\n        let mut output = vec![];\n\n        encode::write_array_len(&mut output, 6)?;\n        encode::write_str(&mut output, &self.id.to_string())?;\n        encode::write_str(&mut output, &self.name)?;\n        encode::write_str(&mut output, &self.description)?;\n        encode::write_str(&mut output, &self.shebang)?;\n        encode::write_array_len(&mut output, self.tags.len() as u32)?;\n\n        for tag in &tags {\n            encode::write_str(&mut output, tag)?;\n        }\n\n        encode::write_str(&mut output, &self.script)?;\n\n        Ok(DecryptedData(output))\n    }\n\n    pub fn deserialize(bytes: &[u8]) -> Result<Self> {\n        let mut bytes = decode::Bytes::new(bytes);\n        let nfields = decode::read_array_len(&mut bytes).unwrap();\n\n        ensure!(nfields == 6, \"too many entries in v0 script record\");\n\n        let bytes = bytes.remaining_slice();\n\n        let (id, bytes) = decode::read_str_from_slice(bytes).unwrap();\n        let (name, bytes) = decode::read_str_from_slice(bytes).unwrap();\n        let (description, bytes) = decode::read_str_from_slice(bytes).unwrap();\n        let (shebang, bytes) = decode::read_str_from_slice(bytes).unwrap();\n\n        let mut bytes = Bytes::new(bytes);\n        let tags_len = decode::read_array_len(&mut bytes).unwrap();\n\n        let mut bytes = bytes.remaining_slice();\n\n        let mut tags = Vec::new();\n        for _ in 0..tags_len {\n            let (tag, remaining) = decode::read_str_from_slice(bytes).unwrap();\n            tags.push(tag.to_owned());\n            bytes = remaining;\n        }\n\n        let (script, bytes) = decode::read_str_from_slice(bytes).unwrap();\n\n        if !bytes.is_empty() {\n            bail!(\"trailing bytes in encoded script record. malformed\")\n        }\n\n        Ok(Script {\n            id: Uuid::parse_str(id).unwrap(),\n            name: name.to_owned(),\n            description: description.to_owned(),\n            shebang: shebang.to_owned(),\n            tags,\n            script: script.to_owned(),\n        })\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn test_serialize() {\n        let script = Script {\n            id: uuid::Uuid::parse_str(\"0195c825a35f7982bdb016168881cbc6\").unwrap(),\n            name: \"test\".to_string(),\n            description: \"test\".to_string(),\n            shebang: \"test\".to_string(),\n            tags: vec![\"test\".to_string()],\n            script: \"test\".to_string(),\n        };\n\n        let serialized = script.serialize().unwrap();\n        assert_eq!(\n            serialized.0,\n            vec![\n                150, 217, 36, 48, 49, 57, 53, 99, 56, 50, 53, 45, 97, 51, 53, 102, 45, 55, 57, 56,\n                50, 45, 98, 100, 98, 48, 45, 49, 54, 49, 54, 56, 56, 56, 49, 99, 98, 99, 54, 164,\n                116, 101, 115, 116, 164, 116, 101, 115, 116, 164, 116, 101, 115, 116, 145, 164,\n                116, 101, 115, 116, 164, 116, 101, 115, 116\n            ]\n        );\n    }\n\n    #[test]\n    fn test_serialize_deserialize() {\n        let script = Script {\n            id: uuid::Uuid::new_v4(),\n            name: \"test\".to_string(),\n            description: \"test\".to_string(),\n            shebang: \"test\".to_string(),\n            tags: vec![\"test\".to_string()],\n            script: \"test\".to_string(),\n        };\n\n        let serialized = script.serialize().unwrap();\n\n        let deserialized = Script::deserialize(&serialized.0).unwrap();\n\n        assert_eq!(script, deserialized);\n    }\n}\n"
  },
  {
    "path": "crates/atuin-scripts/src/store.rs",
    "content": "use eyre::{Result, bail};\n\nuse atuin_client::record::sqlite_store::SqliteStore;\nuse atuin_client::record::{encryption::PASETO_V4, store::Store};\nuse atuin_common::record::{Host, HostId, Record, RecordId, RecordIdx};\nuse record::ScriptRecord;\nuse script::{SCRIPT_TAG, SCRIPT_VERSION, Script};\n\nuse crate::database::Database;\n\npub mod record;\npub mod script;\n\n#[derive(Debug, Clone)]\npub struct ScriptStore {\n    pub store: SqliteStore,\n    pub host_id: HostId,\n    pub encryption_key: [u8; 32],\n}\n\nimpl ScriptStore {\n    pub fn new(store: SqliteStore, host_id: HostId, encryption_key: [u8; 32]) -> Self {\n        ScriptStore {\n            store,\n            host_id,\n            encryption_key,\n        }\n    }\n\n    async fn push_record(&self, record: ScriptRecord) -> Result<(RecordId, RecordIdx)> {\n        let bytes = record.serialize()?;\n        let idx = self\n            .store\n            .last(self.host_id, SCRIPT_TAG)\n            .await?\n            .map_or(0, |p| p.idx + 1);\n\n        let record = Record::builder()\n            .host(Host::new(self.host_id))\n            .version(SCRIPT_VERSION.to_string())\n            .tag(SCRIPT_TAG.to_string())\n            .idx(idx)\n            .data(bytes)\n            .build();\n\n        let id = record.id;\n\n        self.store\n            .push(&record.encrypt::<PASETO_V4>(&self.encryption_key))\n            .await?;\n\n        Ok((id, idx))\n    }\n\n    pub async fn create(&self, script: Script) -> Result<()> {\n        let record = ScriptRecord::Create(script);\n        self.push_record(record).await?;\n        Ok(())\n    }\n\n    pub async fn update(&self, script: Script) -> Result<()> {\n        let record = ScriptRecord::Update(script);\n        self.push_record(record).await?;\n        Ok(())\n    }\n\n    pub async fn delete(&self, script_id: uuid::Uuid) -> Result<()> {\n        let record = ScriptRecord::Delete(script_id);\n        self.push_record(record).await?;\n        Ok(())\n    }\n\n    pub async fn scripts(&self) -> Result<Vec<ScriptRecord>> {\n        let records = self.store.all_tagged(SCRIPT_TAG).await?;\n        let mut ret = Vec::with_capacity(records.len());\n\n        for record in records.into_iter() {\n            let script = match record.version.as_str() {\n                SCRIPT_VERSION => {\n                    let decrypted = record.decrypt::<PASETO_V4>(&self.encryption_key)?;\n\n                    ScriptRecord::deserialize(&decrypted.data, SCRIPT_VERSION)\n                }\n                version => bail!(\"unknown history version {version:?}\"),\n            }?;\n\n            ret.push(script);\n        }\n\n        Ok(ret)\n    }\n\n    pub async fn build(&self, database: Database) -> Result<()> {\n        // Clear existing data before replaying all records from the store.\n        // Without this, stale rows can cause unique constraint violations\n        // when records are replayed (eg name conflicts from renamed scripts).\n        database.clear().await?;\n\n        // Get all the scripts from the store - they are already sorted by timestamp\n        let scripts = self.scripts().await?;\n\n        for script in scripts {\n            match script {\n                ScriptRecord::Create(script) => {\n                    database.save(&script).await?;\n                }\n                ScriptRecord::Update(script) => database.update(&script).await?,\n                ScriptRecord::Delete(id) => database.delete(&id.to_string()).await?,\n            }\n        }\n\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "crates/atuin-server/Cargo.toml",
    "content": "[package]\nname = \"atuin-server\"\nedition = \"2024\"\ndescription = \"server library for atuin\"\n\nrust-version = { workspace = true }\nversion = { workspace = true }\nauthors = { workspace = true }\nlicense = { workspace = true }\nhomepage = { workspace = true }\nrepository = { workspace = true }\n\n[lib]\nname = \"atuin_server\"\npath = \"src/lib.rs\"\n\n[[bin]]\nname = \"atuin-server\"\npath = \"src/bin/main.rs\"\n\n[dependencies]\natuin-common = { workspace = true }\natuin-server-database = { workspace = true }\natuin-server-postgres = { workspace = true }\natuin-server-sqlite = { workspace = true }\n\ntracing = { workspace = true }\ntime = { workspace = true }\neyre = { workspace = true }\nconfig = { workspace = true }\nserde = { workspace = true }\nserde_json = { workspace = true }\nrand = { workspace = true }\ntokio = { workspace = true }\naxum = \"0.8\"\nfs-err = { workspace = true }\ntower = { workspace = true }\ntower-http = { version = \"0.6\", features = [\"trace\"] }\nreqwest = { workspace = true }\nargon2 = \"0.5\"\nsemver = { workspace = true }\nmetrics-exporter-prometheus = { version = \"0.18\", default-features = false }\nmetrics = \"0.24\"\nclap = { workspace = true }\ntracing-subscriber = { workspace = true }\n"
  },
  {
    "path": "crates/atuin-server/server.toml",
    "content": "## host to bind, can also be passed via CLI args\n# host = \"127.0.0.1\"\n\n## port to bind, can also be passed via CLI args\n# port = 8888\n\n## whether to allow anyone to register an account\n# open_registration = false\n\n## URI for postgres (using development creds here)\n# db_uri=\"postgres://username:password@localhost/atuin\"\n# db_uri=\"sqlite:///config/atuin-server.db\"\n\n## Optional: URI for read replica database\n## If set, read-only queries will be routed to this database\n# read_db_uri=\"postgres://username:password@localhost-replica/atuin\"\n\n## Maximum size for one history entry\n# max_history_length = 8192\n\n## Maximum size for one record entry\n## 1024 * 1024 * 1024\n# max_record_size = 1073741824\n\n## Webhook to be called when user registers on the servers\n# register_webhook_username = \"\"\n\n## Default page size for requests\n# page_size = 1100\n\n# [metrics]\n# enable = false\n# host = 127.0.0.1\n# port = 9001\n\n## Enable legacy sync v1 routes (history-based sync)\n## Set to false to disable and use only the newer record-based sync\n# sync_v1_enabled = true\n"
  },
  {
    "path": "crates/atuin-server/src/bin/main.rs",
    "content": "#![forbid(unsafe_code)]\n\nuse std::net::SocketAddr;\n\nuse atuin_server::{Settings, example_config, launch, launch_metrics_server};\nuse atuin_server_database::DbType;\nuse atuin_server_postgres::Postgres;\nuse atuin_server_sqlite::Sqlite;\n\nuse clap::Parser;\nuse eyre::{Context, Result, eyre};\nuse tracing_subscriber::{EnvFilter, fmt, prelude::*};\n\n#[derive(Parser, Debug)]\n#[clap(\n    name = \"atuin-server\",\n    about = \"Atuin sync server\",\n    version,\n    infer_subcommands = true\n)]\nenum Cmd {\n    /// Start the server\n    Start {\n        /// The host address to bind\n        #[clap(long)]\n        host: Option<String>,\n\n        /// The port to bind\n        #[clap(long, short)]\n        port: Option<u16>,\n    },\n\n    /// Print server example configuration\n    DefaultConfig,\n}\n\n#[tokio::main]\nasync fn main() -> Result<()> {\n    let cmd = Cmd::parse();\n\n    tracing_subscriber::registry()\n        .with(fmt::layer())\n        .with(EnvFilter::from_default_env())\n        .init();\n\n    tracing::trace!(command = ?cmd, \"server command\");\n\n    match cmd {\n        Cmd::Start { host, port } => {\n            let settings = Settings::new().wrap_err(\"could not load server settings\")?;\n            let host = host.as_ref().unwrap_or(&settings.host).clone();\n            let port = port.unwrap_or(settings.port);\n            let addr = SocketAddr::new(host.parse()?, port);\n\n            if settings.metrics.enable {\n                tokio::spawn(launch_metrics_server(\n                    settings.metrics.host.clone(),\n                    settings.metrics.port,\n                ));\n            }\n\n            match settings.db_settings.db_type() {\n                DbType::Postgres => launch::<Postgres>(settings, addr).await,\n                DbType::Sqlite => launch::<Sqlite>(settings, addr).await,\n                DbType::Unknown => Err(eyre!(\"db_uri must start with postgres:// or sqlite://\")),\n            }\n        }\n        Cmd::DefaultConfig => {\n            println!(\"{}\", example_config());\n            Ok(())\n        }\n    }\n}\n"
  },
  {
    "path": "crates/atuin-server/src/handlers/health.rs",
    "content": "use axum::{Json, http, response::IntoResponse};\n\nuse serde::Serialize;\n\n#[derive(Serialize)]\npub struct HealthResponse {\n    pub status: &'static str,\n}\n\npub async fn health_check() -> impl IntoResponse {\n    (\n        http::StatusCode::OK,\n        Json(HealthResponse { status: \"healthy\" }),\n    )\n}\n"
  },
  {
    "path": "crates/atuin-server/src/handlers/history.rs",
    "content": "use std::{collections::HashMap, convert::TryFrom};\n\nuse axum::{\n    Json,\n    extract::{Path, Query, State},\n    http::{HeaderMap, StatusCode},\n};\nuse metrics::counter;\nuse time::{Month, UtcOffset};\nuse tracing::{debug, error, instrument};\n\nuse super::{ErrorResponse, ErrorResponseStatus, RespExt};\nuse crate::{\n    router::{AppState, UserAuth},\n    utils::client_version_min,\n};\nuse atuin_server_database::{\n    Database,\n    calendar::{TimePeriod, TimePeriodInfo},\n    models::NewHistory,\n};\n\nuse atuin_common::api::*;\n\n#[instrument(skip_all, fields(user.id = user.id))]\npub async fn count<DB: Database>(\n    UserAuth(user): UserAuth,\n    state: State<AppState<DB>>,\n) -> Result<Json<CountResponse>, ErrorResponseStatus<'static>> {\n    let db = &state.0.database;\n    match db.count_history_cached(&user).await {\n        // By default read out the cached value\n        Ok(count) => Ok(Json(CountResponse { count })),\n\n        // If that fails, fallback on a full COUNT. Cache is built on a POST\n        // only\n        Err(_) => match db.count_history(&user).await {\n            Ok(count) => Ok(Json(CountResponse { count })),\n            Err(_) => Err(ErrorResponse::reply(\"failed to query history count\")\n                .with_status(StatusCode::INTERNAL_SERVER_ERROR)),\n        },\n    }\n}\n\n#[instrument(skip_all, fields(user.id = user.id))]\npub async fn list<DB: Database>(\n    req: Query<SyncHistoryRequest>,\n    UserAuth(user): UserAuth,\n    headers: HeaderMap,\n    state: State<AppState<DB>>,\n) -> Result<Json<SyncHistoryResponse>, ErrorResponseStatus<'static>> {\n    let db = &state.0.database;\n\n    let agent = headers\n        .get(\"user-agent\")\n        .map_or(\"\", |v| v.to_str().unwrap_or(\"\"));\n\n    let variable_page_size = client_version_min(agent, \">=15.0.0\").unwrap_or(false);\n\n    let page_size = if variable_page_size {\n        state.settings.page_size\n    } else {\n        100\n    };\n\n    if req.sync_ts.unix_timestamp_nanos() < 0 || req.history_ts.unix_timestamp_nanos() < 0 {\n        error!(\"client asked for history from < epoch 0\");\n        counter!(\"atuin_history_epoch_before_zero\").increment(1);\n\n        return Err(\n            ErrorResponse::reply(\"asked for history from before epoch 0\")\n                .with_status(StatusCode::BAD_REQUEST),\n        );\n    }\n\n    let history = db\n        .list_history(&user, req.sync_ts, req.history_ts, &req.host, page_size)\n        .await;\n\n    if let Err(e) = history {\n        error!(\"failed to load history: {}\", e);\n        return Err(ErrorResponse::reply(\"failed to load history\")\n            .with_status(StatusCode::INTERNAL_SERVER_ERROR));\n    }\n\n    let history: Vec<String> = history\n        .unwrap()\n        .iter()\n        .map(|i| i.data.to_string())\n        .collect();\n\n    debug!(\n        \"loaded {} items of history for user {}\",\n        history.len(),\n        user.id\n    );\n\n    counter!(\"atuin_history_returned\").increment(history.len() as u64);\n\n    Ok(Json(SyncHistoryResponse { history }))\n}\n\n#[instrument(skip_all, fields(user.id = user.id))]\npub async fn delete<DB: Database>(\n    UserAuth(user): UserAuth,\n    state: State<AppState<DB>>,\n    Json(req): Json<DeleteHistoryRequest>,\n) -> Result<Json<MessageResponse>, ErrorResponseStatus<'static>> {\n    let db = &state.0.database;\n\n    // user_id is the ID of the history, as set by the user (the server has its own ID)\n    let deleted = db.delete_history(&user, req.client_id).await;\n\n    if let Err(e) = deleted {\n        error!(\"failed to delete history: {}\", e);\n        return Err(ErrorResponse::reply(\"failed to delete history\")\n            .with_status(StatusCode::INTERNAL_SERVER_ERROR));\n    }\n\n    Ok(Json(MessageResponse {\n        message: String::from(\"deleted OK\"),\n    }))\n}\n\n#[instrument(skip_all, fields(user.id = user.id))]\npub async fn add<DB: Database>(\n    UserAuth(user): UserAuth,\n    state: State<AppState<DB>>,\n    Json(req): Json<Vec<AddHistoryRequest>>,\n) -> Result<(), ErrorResponseStatus<'static>> {\n    let State(AppState { database, settings }) = state;\n\n    debug!(\"request to add {} history items\", req.len());\n    counter!(\"atuin_history_uploaded\").increment(req.len() as u64);\n\n    let mut history: Vec<NewHistory> = req\n        .into_iter()\n        .map(|h| NewHistory {\n            client_id: h.id,\n            user_id: user.id,\n            hostname: h.hostname,\n            timestamp: h.timestamp,\n            data: h.data,\n        })\n        .collect();\n\n    history.retain(|h| {\n        // keep if within limit, or limit is 0 (unlimited)\n        let keep = h.data.len() <= settings.max_history_length || settings.max_history_length == 0;\n\n        // Don't return an error here. We want to insert as much of the\n        // history list as we can, so log the error and continue going.\n        if !keep {\n            counter!(\"atuin_history_too_long\").increment(1);\n\n            tracing::warn!(\n                \"history too long, got length {}, max {}\",\n                h.data.len(),\n                settings.max_history_length\n            );\n        }\n\n        keep\n    });\n\n    if let Err(e) = database.add_history(&history).await {\n        error!(\"failed to add history: {}\", e);\n\n        return Err(ErrorResponse::reply(\"failed to add history\")\n            .with_status(StatusCode::INTERNAL_SERVER_ERROR));\n    };\n\n    Ok(())\n}\n\n#[derive(serde::Deserialize, Debug)]\npub struct CalendarQuery {\n    #[serde(default = \"serde_calendar::zero\")]\n    year: i32,\n    #[serde(default = \"serde_calendar::one\")]\n    month: u8,\n\n    #[serde(default = \"serde_calendar::utc\")]\n    tz: UtcOffset,\n}\n\nmod serde_calendar {\n    use time::UtcOffset;\n\n    pub fn zero() -> i32 {\n        0\n    }\n\n    pub fn one() -> u8 {\n        1\n    }\n\n    pub fn utc() -> UtcOffset {\n        UtcOffset::UTC\n    }\n}\n\n#[instrument(skip_all, fields(user.id = user.id))]\npub async fn calendar<DB: Database>(\n    Path(focus): Path<String>,\n    Query(params): Query<CalendarQuery>,\n    UserAuth(user): UserAuth,\n    state: State<AppState<DB>>,\n) -> Result<Json<HashMap<u64, TimePeriodInfo>>, ErrorResponseStatus<'static>> {\n    let focus = focus.as_str();\n\n    let year = params.year;\n    let month = Month::try_from(params.month).map_err(|e| ErrorResponseStatus {\n        error: ErrorResponse {\n            reason: e.to_string().into(),\n        },\n        status: StatusCode::BAD_REQUEST,\n    })?;\n\n    let period = match focus {\n        \"year\" => TimePeriod::Year,\n        \"month\" => TimePeriod::Month { year },\n        \"day\" => TimePeriod::Day { year, month },\n        _ => {\n            return Err(ErrorResponse::reply(\"invalid focus: use year/month/day\")\n                .with_status(StatusCode::BAD_REQUEST));\n        }\n    };\n\n    let db = &state.0.database;\n    let focus = db.calendar(&user, period, params.tz).await.map_err(|_| {\n        ErrorResponse::reply(\"failed to query calendar\")\n            .with_status(StatusCode::INTERNAL_SERVER_ERROR)\n    })?;\n\n    Ok(Json(focus))\n}\n"
  },
  {
    "path": "crates/atuin-server/src/handlers/mod.rs",
    "content": "use atuin_common::api::{ErrorResponse, IndexResponse};\nuse atuin_server_database::Database;\nuse axum::{Json, extract::State, http, response::IntoResponse};\n\nuse crate::router::AppState;\n\npub mod health;\npub mod history;\npub mod record;\npub mod status;\npub mod user;\npub mod v0;\n\nconst VERSION: &str = env!(\"CARGO_PKG_VERSION\");\n\npub async fn index<DB: Database>(state: State<AppState<DB>>) -> Json<IndexResponse> {\n    let homage = r#\"\"Through the fathomless deeps of space swims the star turtle Great A'Tuin, bearing on its back the four giant elephants who carry on their shoulders the mass of the Discworld.\" -- Sir Terry Pratchett\"#;\n\n    let version = state\n        .settings\n        .fake_version\n        .clone()\n        .unwrap_or(VERSION.to_string());\n\n    Json(IndexResponse {\n        homage: homage.to_string(),\n        version,\n    })\n}\n\nimpl IntoResponse for ErrorResponseStatus<'_> {\n    fn into_response(self) -> axum::response::Response {\n        (self.status, Json(self.error)).into_response()\n    }\n}\n\npub struct ErrorResponseStatus<'a> {\n    pub error: ErrorResponse<'a>,\n    pub status: http::StatusCode,\n}\n\npub trait RespExt<'a> {\n    fn with_status(self, status: http::StatusCode) -> ErrorResponseStatus<'a>;\n    fn reply(reason: &'a str) -> Self;\n}\n\nimpl<'a> RespExt<'a> for ErrorResponse<'a> {\n    fn with_status(self, status: http::StatusCode) -> ErrorResponseStatus<'a> {\n        ErrorResponseStatus {\n            error: self,\n            status,\n        }\n    }\n\n    fn reply(reason: &'a str) -> ErrorResponse<'a> {\n        Self {\n            reason: reason.into(),\n        }\n    }\n}\n"
  },
  {
    "path": "crates/atuin-server/src/handlers/record.rs",
    "content": "use axum::{Json, http::StatusCode, response::IntoResponse};\nuse serde_json::json;\nuse tracing::instrument;\n\nuse super::{ErrorResponse, ErrorResponseStatus, RespExt};\nuse crate::router::UserAuth;\n\nuse atuin_common::record::{EncryptedData, Record};\n\n#[instrument(skip_all, fields(user.id = user.id))]\npub async fn post(UserAuth(user): UserAuth) -> Result<(), ErrorResponseStatus<'static>> {\n    // anyone who has actually used the old record store (a very small number) will see this error\n    // upon trying to sync.\n    // 1. The status endpoint will say that the server has nothing\n    // 2. The client will try to upload local records\n    // 3. Sync will fail with this error\n\n    // If the client has no local records, they will see the empty index and do nothing. For the\n    // vast majority of users, this is the case.\n    return Err(\n        ErrorResponse::reply(\"record store deprecated; please upgrade\")\n            .with_status(StatusCode::BAD_REQUEST),\n    );\n}\n\n#[instrument(skip_all, fields(user.id = user.id))]\npub async fn index(UserAuth(user): UserAuth) -> axum::response::Response {\n    let ret = json!({\n        \"hosts\": {}\n    });\n\n    ret.to_string().into_response()\n}\n\n#[instrument(skip_all, fields(user.id = user.id))]\npub async fn next(\n    UserAuth(user): UserAuth,\n) -> Result<Json<Vec<Record<EncryptedData>>>, ErrorResponseStatus<'static>> {\n    let records = Vec::new();\n\n    Ok(Json(records))\n}\n"
  },
  {
    "path": "crates/atuin-server/src/handlers/status.rs",
    "content": "use axum::{Json, extract::State, http::StatusCode};\nuse tracing::instrument;\n\nuse super::{ErrorResponse, ErrorResponseStatus, RespExt};\nuse crate::router::{AppState, UserAuth};\nuse atuin_server_database::Database;\n\nuse atuin_common::api::*;\n\nconst VERSION: &str = env!(\"CARGO_PKG_VERSION\");\n\n#[instrument(skip_all, fields(user.id = user.id))]\npub async fn status<DB: Database>(\n    UserAuth(user): UserAuth,\n    state: State<AppState<DB>>,\n) -> Result<Json<StatusResponse>, ErrorResponseStatus<'static>> {\n    let db = &state.0.database;\n\n    let deleted = db.deleted_history(&user).await.unwrap_or(vec![]);\n\n    let count = match db.count_history_cached(&user).await {\n        // By default read out the cached value\n        Ok(count) => count,\n\n        // If that fails, fallback on a full COUNT. Cache is built on a POST\n        // only\n        Err(_) => match db.count_history(&user).await {\n            Ok(count) => count,\n            Err(_) => {\n                return Err(ErrorResponse::reply(\"failed to query history count\")\n                    .with_status(StatusCode::INTERNAL_SERVER_ERROR));\n            }\n        },\n    };\n\n    tracing::debug!(user = user.username, \"requested sync status\");\n\n    Ok(Json(StatusResponse {\n        count,\n        deleted,\n        username: user.username,\n        version: VERSION.to_string(),\n        page_size: state.settings.page_size,\n    }))\n}\n"
  },
  {
    "path": "crates/atuin-server/src/handlers/user.rs",
    "content": "use std::borrow::Borrow;\nuse std::collections::HashMap;\nuse std::time::Duration;\n\nuse argon2::{\n    Algorithm, Argon2, Params, PasswordHash, PasswordHasher, PasswordVerifier, Version,\n    password_hash::SaltString,\n};\nuse axum::{\n    Json,\n    extract::{Path, State},\n    http::StatusCode,\n};\nuse metrics::counter;\n\nuse rand::rngs::OsRng;\nuse tracing::{debug, error, info, instrument};\n\nuse atuin_common::tls::ensure_crypto_provider;\n\nuse super::{ErrorResponse, ErrorResponseStatus, RespExt};\nuse crate::router::{AppState, UserAuth};\nuse atuin_server_database::{\n    Database, DbError,\n    models::{NewSession, NewUser},\n};\n\nuse reqwest::header::CONTENT_TYPE;\n\nuse atuin_common::{api::*, utils::crypto_random_string};\n\npub fn verify_str(hash: &str, password: &str) -> bool {\n    let arg2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, Params::default());\n    let Ok(hash) = PasswordHash::new(hash) else {\n        return false;\n    };\n    arg2.verify_password(password.as_bytes(), &hash).is_ok()\n}\n\n// Try to send a Discord webhook once - if it fails, we don't retry. \"At most once\", and best effort.\n// Don't return the status because if this fails, we don't really care.\nasync fn send_register_hook(url: &str, username: String, registered: String) {\n    ensure_crypto_provider();\n    let hook = HashMap::from([\n        (\"username\", username),\n        (\"content\", format!(\"{registered} has just signed up!\")),\n    ]);\n\n    let client = reqwest::Client::new();\n\n    let resp = client\n        .post(url)\n        .timeout(Duration::new(5, 0))\n        .header(CONTENT_TYPE, \"application/json\")\n        .json(&hook)\n        .send()\n        .await;\n\n    match resp {\n        Ok(_) => info!(\"register webhook sent ok!\"),\n        Err(e) => error!(\"failed to send register webhook: {}\", e),\n    }\n}\n\n#[instrument(skip_all, fields(user.username = username.as_str()))]\npub async fn get<DB: Database>(\n    Path(username): Path<String>,\n    state: State<AppState<DB>>,\n) -> Result<Json<UserResponse>, ErrorResponseStatus<'static>> {\n    let db = &state.0.database;\n    let user = match db.get_user(username.as_ref()).await {\n        Ok(user) => user,\n        Err(DbError::NotFound) => {\n            debug!(\"user not found: {}\", username);\n            return Err(ErrorResponse::reply(\"user not found\").with_status(StatusCode::NOT_FOUND));\n        }\n        Err(DbError::Other(err)) => {\n            error!(\"database error: {}\", err);\n            return Err(ErrorResponse::reply(\"database error\")\n                .with_status(StatusCode::INTERNAL_SERVER_ERROR));\n        }\n    };\n\n    Ok(Json(UserResponse {\n        username: user.username,\n    }))\n}\n\n#[instrument(skip_all)]\npub async fn register<DB: Database>(\n    state: State<AppState<DB>>,\n    Json(register): Json<RegisterRequest>,\n) -> Result<Json<RegisterResponse>, ErrorResponseStatus<'static>> {\n    if !state.settings.open_registration {\n        return Err(\n            ErrorResponse::reply(\"this server is not open for registrations\")\n                .with_status(StatusCode::BAD_REQUEST),\n        );\n    }\n\n    for c in register.username.chars() {\n        match c {\n            'a'..='z' | 'A'..='Z' | '0'..='9' | '-' => {}\n            _ => {\n                return Err(ErrorResponse::reply(\n                    \"Only alphanumeric and hyphens (-) are allowed in usernames\",\n                )\n                .with_status(StatusCode::BAD_REQUEST));\n            }\n        }\n    }\n\n    let hashed = hash_secret(&register.password);\n\n    let new_user = NewUser {\n        email: register.email.clone(),\n        username: register.username.clone(),\n        password: hashed,\n    };\n\n    let db = &state.0.database;\n    let user_id = match db.add_user(&new_user).await {\n        Ok(id) => id,\n        Err(e) => {\n            error!(\"failed to add user: {}\", e);\n            return Err(\n                ErrorResponse::reply(\"failed to add user\").with_status(StatusCode::BAD_REQUEST)\n            );\n        }\n    };\n\n    // 24 bytes encoded as base64\n    let token = crypto_random_string::<24>();\n\n    let new_session = NewSession {\n        user_id,\n        token: (&token).into(),\n    };\n\n    if let Some(url) = &state.settings.register_webhook_url {\n        // Could probs be run on another thread, but it's ok atm\n        send_register_hook(\n            url,\n            state.settings.register_webhook_username.clone(),\n            register.username,\n        )\n        .await;\n    }\n\n    counter!(\"atuin_users_registered\").increment(1);\n\n    match db.add_session(&new_session).await {\n        Ok(_) => Ok(Json(RegisterResponse { session: token })),\n        Err(e) => {\n            error!(\"failed to add session: {}\", e);\n            Err(ErrorResponse::reply(\"failed to register user\")\n                .with_status(StatusCode::BAD_REQUEST))\n        }\n    }\n}\n\n#[instrument(skip_all, fields(user.id = user.id))]\npub async fn delete<DB: Database>(\n    UserAuth(user): UserAuth,\n    state: State<AppState<DB>>,\n) -> Result<Json<DeleteUserResponse>, ErrorResponseStatus<'static>> {\n    debug!(\"request to delete user {}\", user.id);\n\n    let db = &state.0.database;\n    if let Err(e) = db.delete_user(&user).await {\n        error!(\"failed to delete user: {}\", e);\n\n        return Err(ErrorResponse::reply(\"failed to delete user\")\n            .with_status(StatusCode::INTERNAL_SERVER_ERROR));\n    };\n\n    counter!(\"atuin_users_deleted\").increment(1);\n\n    Ok(Json(DeleteUserResponse {}))\n}\n\n#[instrument(skip_all, fields(user.id = user.id, change_password))]\npub async fn change_password<DB: Database>(\n    UserAuth(mut user): UserAuth,\n    state: State<AppState<DB>>,\n    Json(change_password): Json<ChangePasswordRequest>,\n) -> Result<Json<ChangePasswordResponse>, ErrorResponseStatus<'static>> {\n    let db = &state.0.database;\n\n    let verified = verify_str(\n        user.password.as_str(),\n        change_password.current_password.borrow(),\n    );\n    if !verified {\n        return Err(\n            ErrorResponse::reply(\"password is not correct\").with_status(StatusCode::UNAUTHORIZED)\n        );\n    }\n\n    let hashed = hash_secret(&change_password.new_password);\n    user.password = hashed;\n\n    if let Err(e) = db.update_user_password(&user).await {\n        error!(\"failed to change user password: {}\", e);\n\n        return Err(ErrorResponse::reply(\"failed to change user password\")\n            .with_status(StatusCode::INTERNAL_SERVER_ERROR));\n    };\n    Ok(Json(ChangePasswordResponse {}))\n}\n\n#[instrument(skip_all, fields(user.username = login.username.as_str()))]\npub async fn login<DB: Database>(\n    state: State<AppState<DB>>,\n    login: Json<LoginRequest>,\n) -> Result<Json<LoginResponse>, ErrorResponseStatus<'static>> {\n    let db = &state.0.database;\n    let user = match db.get_user(login.username.borrow()).await {\n        Ok(u) => u,\n        Err(DbError::NotFound) => {\n            return Err(ErrorResponse::reply(\"user not found\").with_status(StatusCode::NOT_FOUND));\n        }\n        Err(DbError::Other(e)) => {\n            error!(\"failed to get user {}: {}\", login.username.clone(), e);\n\n            return Err(ErrorResponse::reply(\"database error\")\n                .with_status(StatusCode::INTERNAL_SERVER_ERROR));\n        }\n    };\n\n    let session = match db.get_user_session(&user).await {\n        Ok(u) => u,\n        Err(DbError::NotFound) => {\n            debug!(\"user session not found for user id={}\", user.id);\n            return Err(ErrorResponse::reply(\"user not found\").with_status(StatusCode::NOT_FOUND));\n        }\n        Err(DbError::Other(err)) => {\n            error!(\"database error for user {}: {}\", login.username, err);\n            return Err(ErrorResponse::reply(\"database error\")\n                .with_status(StatusCode::INTERNAL_SERVER_ERROR));\n        }\n    };\n\n    let verified = verify_str(user.password.as_str(), login.password.borrow());\n\n    if !verified {\n        debug!(user = user.username, \"login failed\");\n        return Err(\n            ErrorResponse::reply(\"password is not correct\").with_status(StatusCode::UNAUTHORIZED)\n        );\n    }\n\n    debug!(user = user.username, \"login success\");\n\n    Ok(Json(LoginResponse {\n        session: session.token,\n    }))\n}\n\nfn hash_secret(password: &str) -> String {\n    let arg2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, Params::default());\n    let salt = SaltString::generate(&mut OsRng);\n    let hash = arg2.hash_password(password.as_bytes(), &salt).unwrap();\n    hash.to_string()\n}\n"
  },
  {
    "path": "crates/atuin-server/src/handlers/v0/me.rs",
    "content": "use axum::Json;\nuse tracing::instrument;\n\nuse crate::handlers::ErrorResponseStatus;\nuse crate::router::UserAuth;\n\nuse atuin_common::api::*;\n\n#[instrument(skip_all, fields(user.id = user.id))]\npub async fn get(\n    UserAuth(user): UserAuth,\n) -> Result<Json<MeResponse>, ErrorResponseStatus<'static>> {\n    Ok(Json(MeResponse {\n        username: user.username,\n    }))\n}\n"
  },
  {
    "path": "crates/atuin-server/src/handlers/v0/mod.rs",
    "content": "pub(crate) mod me;\npub(crate) mod record;\npub(crate) mod store;\n"
  },
  {
    "path": "crates/atuin-server/src/handlers/v0/record.rs",
    "content": "use axum::{Json, extract::Query, extract::State, http::StatusCode};\nuse metrics::counter;\nuse serde::Deserialize;\nuse tracing::{error, instrument};\n\nuse crate::{\n    handlers::{ErrorResponse, ErrorResponseStatus, RespExt},\n    router::{AppState, UserAuth},\n};\nuse atuin_server_database::Database;\n\nuse atuin_common::record::{EncryptedData, HostId, Record, RecordIdx, RecordStatus};\n\n#[instrument(skip_all, fields(user.id = user.id))]\npub async fn post<DB: Database>(\n    UserAuth(user): UserAuth,\n    state: State<AppState<DB>>,\n    Json(records): Json<Vec<Record<EncryptedData>>>,\n) -> Result<(), ErrorResponseStatus<'static>> {\n    let State(AppState { database, settings }) = state;\n\n    tracing::debug!(\n        count = records.len(),\n        user = user.username,\n        \"request to add records\"\n    );\n\n    counter!(\"atuin_record_uploaded\").increment(records.len() as u64);\n\n    let keep = records\n        .iter()\n        .all(|r| r.data.data.len() <= settings.max_record_size || settings.max_record_size == 0);\n\n    if !keep {\n        counter!(\"atuin_record_too_large\").increment(1);\n\n        return Err(\n            ErrorResponse::reply(\"could not add records; record too large\")\n                .with_status(StatusCode::BAD_REQUEST),\n        );\n    }\n\n    if let Err(e) = database.add_records(&user, &records).await {\n        error!(\"failed to add record: {}\", e);\n\n        return Err(ErrorResponse::reply(\"failed to add record\")\n            .with_status(StatusCode::INTERNAL_SERVER_ERROR));\n    };\n\n    Ok(())\n}\n\n#[instrument(skip_all, fields(user.id = user.id))]\npub async fn index<DB: Database>(\n    UserAuth(user): UserAuth,\n    state: State<AppState<DB>>,\n) -> Result<Json<RecordStatus>, ErrorResponseStatus<'static>> {\n    let State(AppState {\n        database,\n        settings: _,\n    }) = state;\n\n    let record_index = match database.status(&user).await {\n        Ok(index) => index,\n        Err(e) => {\n            error!(\"failed to get record index: {}\", e);\n\n            return Err(ErrorResponse::reply(\"failed to calculate record index\")\n                .with_status(StatusCode::INTERNAL_SERVER_ERROR));\n        }\n    };\n\n    tracing::debug!(user = user.username, \"record index request\");\n\n    Ok(Json(record_index))\n}\n\n#[derive(Deserialize)]\npub struct NextParams {\n    host: HostId,\n    tag: String,\n    start: Option<RecordIdx>,\n    count: u64,\n}\n\n#[instrument(skip_all, fields(user.id = user.id))]\npub async fn next<DB: Database>(\n    params: Query<NextParams>,\n    UserAuth(user): UserAuth,\n    state: State<AppState<DB>>,\n) -> Result<Json<Vec<Record<EncryptedData>>>, ErrorResponseStatus<'static>> {\n    let State(AppState {\n        database,\n        settings: _,\n    }) = state;\n    let params = params.0;\n\n    let records = match database\n        .next_records(&user, params.host, params.tag, params.start, params.count)\n        .await\n    {\n        Ok(records) => records,\n        Err(e) => {\n            error!(\"failed to get record index: {}\", e);\n\n            return Err(ErrorResponse::reply(\"failed to calculate record index\")\n                .with_status(StatusCode::INTERNAL_SERVER_ERROR));\n        }\n    };\n\n    counter!(\"atuin_record_downloaded\").increment(records.len() as u64);\n\n    Ok(Json(records))\n}\n"
  },
  {
    "path": "crates/atuin-server/src/handlers/v0/store.rs",
    "content": "use axum::{extract::Query, extract::State, http::StatusCode};\nuse metrics::counter;\nuse serde::Deserialize;\nuse tracing::{error, instrument};\n\nuse crate::{\n    handlers::{ErrorResponse, ErrorResponseStatus, RespExt},\n    router::{AppState, UserAuth},\n};\nuse atuin_server_database::Database;\n\n#[derive(Deserialize)]\npub struct DeleteParams {}\n\n#[instrument(skip_all, fields(user.id = user.id))]\npub async fn delete<DB: Database>(\n    _params: Query<DeleteParams>,\n    UserAuth(user): UserAuth,\n    state: State<AppState<DB>>,\n) -> Result<(), ErrorResponseStatus<'static>> {\n    let State(AppState {\n        database,\n        settings: _,\n    }) = state;\n\n    if let Err(e) = database.delete_store(&user).await {\n        counter!(\"atuin_store_delete_failed\").increment(1);\n        error!(\"failed to delete store {e:?}\");\n\n        return Err(ErrorResponse::reply(\"failed to delete store\")\n            .with_status(StatusCode::INTERNAL_SERVER_ERROR));\n    }\n\n    counter!(\"atuin_store_deleted\").increment(1);\n\n    Ok(())\n}\n"
  },
  {
    "path": "crates/atuin-server/src/lib.rs",
    "content": "#![forbid(unsafe_code)]\n\nuse std::future::Future;\nuse std::net::SocketAddr;\n\nuse atuin_server_database::Database;\nuse axum::{Router, serve};\nuse eyre::{Context, Result};\n\nmod handlers;\nmod metrics;\nmod router;\nmod utils;\n\npub use settings::Settings;\npub use settings::example_config;\n\npub mod settings;\n\nuse tokio::net::TcpListener;\nuse tokio::signal;\n\n#[cfg(target_family = \"unix\")]\nasync fn shutdown_signal() {\n    let mut term = signal::unix::signal(signal::unix::SignalKind::terminate())\n        .expect(\"failed to register signal handler\");\n    let mut interrupt = signal::unix::signal(signal::unix::SignalKind::interrupt())\n        .expect(\"failed to register signal handler\");\n\n    tokio::select! {\n        _ = term.recv() => {},\n        _ = interrupt.recv() => {},\n    };\n    eprintln!(\"Shutting down gracefully...\");\n}\n\n#[cfg(target_family = \"windows\")]\nasync fn shutdown_signal() {\n    signal::windows::ctrl_c()\n        .expect(\"failed to register signal handler\")\n        .recv()\n        .await;\n    eprintln!(\"Shutting down gracefully...\");\n}\n\npub async fn launch<Db: Database>(settings: Settings, addr: SocketAddr) -> Result<()> {\n    launch_with_tcp_listener::<Db>(\n        settings,\n        TcpListener::bind(addr)\n            .await\n            .context(\"could not connect to socket\")?,\n        shutdown_signal(),\n    )\n    .await\n}\n\npub async fn launch_with_tcp_listener<Db: Database>(\n    settings: Settings,\n    listener: TcpListener,\n    shutdown: impl Future<Output = ()> + Send + 'static,\n) -> Result<()> {\n    let r = make_router::<Db>(settings).await?;\n\n    serve(listener, r.into_make_service())\n        .with_graceful_shutdown(shutdown)\n        .await?;\n\n    Ok(())\n}\n\n// The separate listener means it's much easier to ensure metrics are not accidentally exposed to\n// the public.\npub async fn launch_metrics_server(host: String, port: u16) -> Result<()> {\n    let listener = TcpListener::bind((host, port))\n        .await\n        .context(\"failed to bind metrics tcp\")?;\n\n    let recorder_handle = metrics::setup_metrics_recorder();\n\n    let router = Router::new().route(\n        \"/metrics\",\n        axum::routing::get(move || std::future::ready(recorder_handle.render())),\n    );\n\n    serve(listener, router.into_make_service())\n        .with_graceful_shutdown(shutdown_signal())\n        .await?;\n\n    Ok(())\n}\n\nasync fn make_router<Db: Database>(settings: Settings) -> Result<Router, eyre::Error> {\n    let db = Db::new(&settings.db_settings)\n        .await\n        .wrap_err_with(|| format!(\"failed to connect to db: {:?}\", settings.db_settings))?;\n    let r = router::router(db, settings);\n    Ok(r)\n}\n"
  },
  {
    "path": "crates/atuin-server/src/metrics.rs",
    "content": "use std::time::Instant;\n\nuse axum::{\n    extract::{MatchedPath, Request},\n    middleware::Next,\n    response::IntoResponse,\n};\nuse metrics_exporter_prometheus::{Matcher, PrometheusBuilder, PrometheusHandle};\n\npub fn setup_metrics_recorder() -> PrometheusHandle {\n    const EXPONENTIAL_SECONDS: &[f64] = &[\n        0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0,\n    ];\n\n    PrometheusBuilder::new()\n        .set_buckets_for_metric(\n            Matcher::Full(\"http_requests_duration_seconds\".to_string()),\n            EXPONENTIAL_SECONDS,\n        )\n        .unwrap()\n        .install_recorder()\n        .unwrap()\n}\n\n/// Middleware to record some common HTTP metrics\n/// Generic over B to allow for arbitrary body types (eg Vec<u8>, Streams, a deserialized thing, etc)\n/// Someday tower-http might provide a metrics middleware: https://github.com/tower-rs/tower-http/issues/57\npub async fn track_metrics(req: Request, next: Next) -> impl IntoResponse {\n    let start = Instant::now();\n\n    let path = match req.extensions().get::<MatchedPath>() {\n        Some(matched_path) => matched_path.as_str().to_owned(),\n        _ => req.uri().path().to_owned(),\n    };\n\n    let method = req.method().clone();\n\n    // Run the rest of the request handling first, so we can measure it and get response\n    // codes.\n    let response = next.run(req).await;\n\n    let latency = start.elapsed().as_secs_f64();\n    let status = response.status().as_u16().to_string();\n\n    let labels = [\n        (\"method\", method.to_string()),\n        (\"path\", path),\n        (\"status\", status),\n    ];\n\n    metrics::counter!(\"http_requests_total\", &labels).increment(1);\n    metrics::histogram!(\"http_requests_duration_seconds\", &labels).record(latency);\n\n    response\n}\n"
  },
  {
    "path": "crates/atuin-server/src/router.rs",
    "content": "use atuin_common::api::{ATUIN_CARGO_VERSION, ATUIN_HEADER_VERSION, ErrorResponse};\nuse axum::{\n    Router,\n    extract::{FromRequestParts, Request},\n    http::{self, request::Parts},\n    middleware::Next,\n    response::{IntoResponse, Response},\n    routing::{delete, get, patch, post},\n};\nuse eyre::Result;\nuse tower::ServiceBuilder;\nuse tower_http::trace::TraceLayer;\n\nuse super::handlers;\nuse crate::{\n    handlers::{ErrorResponseStatus, RespExt},\n    metrics,\n    settings::Settings,\n};\nuse atuin_server_database::{Database, DbError, models::User};\n\npub struct UserAuth(pub User);\n\nimpl<DB: Send + Sync> FromRequestParts<AppState<DB>> for UserAuth\nwhere\n    DB: Database,\n{\n    type Rejection = ErrorResponseStatus<'static>;\n\n    async fn from_request_parts(\n        req: &mut Parts,\n        state: &AppState<DB>,\n    ) -> Result<Self, Self::Rejection> {\n        let auth_header = req\n            .headers\n            .get(http::header::AUTHORIZATION)\n            .ok_or_else(|| {\n                ErrorResponse::reply(\"missing authorization header\")\n                    .with_status(http::StatusCode::BAD_REQUEST)\n            })?;\n        let auth_header = auth_header.to_str().map_err(|_| {\n            ErrorResponse::reply(\"invalid authorization header encoding\")\n                .with_status(http::StatusCode::BAD_REQUEST)\n        })?;\n        let (typ, token) = auth_header.split_once(' ').ok_or_else(|| {\n            ErrorResponse::reply(\"invalid authorization header encoding\")\n                .with_status(http::StatusCode::BAD_REQUEST)\n        })?;\n\n        if typ != \"Token\" {\n            return Err(\n                ErrorResponse::reply(\"invalid authorization header encoding\")\n                    .with_status(http::StatusCode::BAD_REQUEST),\n            );\n        }\n\n        let user = state\n            .database\n            .get_session_user(token)\n            .await\n            .map_err(|e| match e {\n                DbError::NotFound => ErrorResponse::reply(\"session not found\")\n                    .with_status(http::StatusCode::FORBIDDEN),\n                DbError::Other(e) => {\n                    tracing::error!(error = ?e, \"could not query user session\");\n                    ErrorResponse::reply(\"could not query user session\")\n                        .with_status(http::StatusCode::INTERNAL_SERVER_ERROR)\n                }\n            })?;\n\n        Ok(UserAuth(user))\n    }\n}\n\nasync fn teapot() -> impl IntoResponse {\n    // This used to return 418: 🫖\n    // Much as it was fun, it wasn't as useful or informative as it should be\n    (http::StatusCode::NOT_FOUND, \"404 not found\")\n}\n\nasync fn clacks_overhead(request: Request, next: Next) -> Response {\n    let mut response = next.run(request).await;\n\n    let gnu_terry_value = \"GNU Terry Pratchett, Kris Nova\";\n    let gnu_terry_header = \"X-Clacks-Overhead\";\n\n    response\n        .headers_mut()\n        .insert(gnu_terry_header, gnu_terry_value.parse().unwrap());\n    response\n}\n\n/// Ensure that we only try and sync with clients on the same major version\nasync fn semver(request: Request, next: Next) -> Response {\n    let mut response = next.run(request).await;\n    response\n        .headers_mut()\n        .insert(ATUIN_HEADER_VERSION, ATUIN_CARGO_VERSION.parse().unwrap());\n\n    response\n}\n\n#[derive(Clone)]\npub struct AppState<DB: Database> {\n    pub database: DB,\n    pub settings: Settings,\n}\n\npub fn router<DB: Database>(database: DB, settings: Settings) -> Router {\n    let mut routes = Router::new()\n        .route(\"/\", get(handlers::index))\n        .route(\"/healthz\", get(handlers::health::health_check));\n\n    // Sync v1 routes - can be disabled in favor of record-based sync\n    if settings.sync_v1_enabled {\n        routes = routes\n            .route(\"/sync/count\", get(handlers::history::count))\n            .route(\"/sync/history\", get(handlers::history::list))\n            .route(\"/sync/calendar/{focus}\", get(handlers::history::calendar))\n            .route(\"/sync/status\", get(handlers::status::status))\n            .route(\"/history\", post(handlers::history::add))\n            .route(\"/history\", delete(handlers::history::delete));\n    }\n\n    let routes = routes\n        .route(\"/user/{username}\", get(handlers::user::get))\n        .route(\"/account\", delete(handlers::user::delete))\n        .route(\"/account/password\", patch(handlers::user::change_password))\n        .route(\"/register\", post(handlers::user::register))\n        .route(\"/login\", post(handlers::user::login))\n        .route(\"/record\", post(handlers::record::post))\n        .route(\"/record\", get(handlers::record::index))\n        .route(\"/record/next\", get(handlers::record::next))\n        .route(\"/api/v0/me\", get(handlers::v0::me::get))\n        .route(\"/api/v0/record\", post(handlers::v0::record::post))\n        .route(\"/api/v0/record\", get(handlers::v0::record::index))\n        .route(\"/api/v0/record/next\", get(handlers::v0::record::next))\n        .route(\"/api/v0/store\", delete(handlers::v0::store::delete));\n\n    let path = settings.path.as_str();\n    if path.is_empty() {\n        routes\n    } else {\n        Router::new().nest(path, routes)\n    }\n    .fallback(teapot)\n    .with_state(AppState { database, settings })\n    .layer(\n        ServiceBuilder::new()\n            .layer(axum::middleware::from_fn(clacks_overhead))\n            .layer(TraceLayer::new_for_http())\n            .layer(axum::middleware::from_fn(metrics::track_metrics))\n            .layer(axum::middleware::from_fn(semver)),\n    )\n}\n"
  },
  {
    "path": "crates/atuin-server/src/settings.rs",
    "content": "use std::{io::prelude::*, path::PathBuf};\n\nuse atuin_server_database::DbSettings;\nuse config::{Config, Environment, File as ConfigFile, FileFormat};\nuse eyre::{Result, eyre};\nuse fs_err::{File, create_dir_all};\nuse serde::{Deserialize, Serialize};\n\nstatic EXAMPLE_CONFIG: &str = include_str!(\"../server.toml\");\n\n#[derive(Clone, Debug, Deserialize, Serialize)]\npub struct Metrics {\n    #[serde(alias = \"enabled\")]\n    pub enable: bool,\n    pub host: String,\n    pub port: u16,\n}\n\nimpl Default for Metrics {\n    fn default() -> Self {\n        Self {\n            enable: false,\n            host: String::from(\"127.0.0.1\"),\n            port: 9001,\n        }\n    }\n}\n\n#[derive(Clone, Debug, Deserialize, Serialize)]\npub struct Settings {\n    pub host: String,\n    pub port: u16,\n    pub path: String,\n    pub open_registration: bool,\n    pub max_history_length: usize,\n    pub max_record_size: usize,\n    pub page_size: i64,\n    pub register_webhook_url: Option<String>,\n    pub register_webhook_username: String,\n    pub metrics: Metrics,\n\n    /// Enable legacy sync v1 routes (history-based sync)\n    /// Set to false to use only the newer record-based sync\n    pub sync_v1_enabled: bool,\n\n    /// Advertise a version that is not what we are _actually_ running\n    /// Many clients compare their version with api.atuin.sh, and if they differ, notify the user\n    /// that an update is available.\n    /// Now that we take beta releases, we should be able to advertise a different version to avoid\n    /// notifying users when the server runs something that is not a stable release.\n    pub fake_version: Option<String>,\n\n    #[serde(flatten)]\n    pub db_settings: DbSettings,\n}\n\nimpl Settings {\n    pub fn new() -> Result<Self> {\n        let mut config_file = if let Ok(p) = std::env::var(\"ATUIN_CONFIG_DIR\") {\n            PathBuf::from(p)\n        } else {\n            let mut config_file = PathBuf::new();\n            let config_dir = atuin_common::utils::config_dir();\n            config_file.push(config_dir);\n            config_file\n        };\n\n        config_file.push(\"server.toml\");\n\n        // create the config file if it does not exist\n        let mut config_builder = Config::builder()\n            .set_default(\"host\", \"127.0.0.1\")?\n            .set_default(\"port\", 8888)?\n            .set_default(\"open_registration\", false)?\n            .set_default(\"max_history_length\", 8192)?\n            .set_default(\"max_record_size\", 1024 * 1024 * 1024)? // pretty chonky\n            .set_default(\"path\", \"\")?\n            .set_default(\"register_webhook_username\", \"\")?\n            .set_default(\"page_size\", 1100)?\n            .set_default(\"metrics.enable\", false)?\n            .set_default(\"metrics.host\", \"127.0.0.1\")?\n            .set_default(\"metrics.port\", 9001)?\n            .set_default(\"sync_v1_enabled\", true)?\n            .add_source(\n                Environment::with_prefix(\"atuin\")\n                    .prefix_separator(\"_\")\n                    .separator(\"__\"),\n            );\n\n        config_builder = if config_file.exists() {\n            config_builder.add_source(ConfigFile::new(\n                config_file.to_str().unwrap(),\n                FileFormat::Toml,\n            ))\n        } else {\n            create_dir_all(config_file.parent().unwrap())?;\n            let mut file = File::create(config_file)?;\n            file.write_all(EXAMPLE_CONFIG.as_bytes())?;\n\n            config_builder\n        };\n\n        let config = config_builder.build()?;\n\n        config\n            .try_deserialize()\n            .map_err(|e| eyre!(\"failed to deserialize: {}\", e))\n    }\n}\n\npub fn example_config() -> &'static str {\n    EXAMPLE_CONFIG\n}\n"
  },
  {
    "path": "crates/atuin-server/src/utils.rs",
    "content": "use eyre::Result;\nuse semver::{Version, VersionReq};\n\npub fn client_version_min(user_agent: &str, req: &str) -> Result<bool> {\n    if user_agent.is_empty() {\n        return Ok(false);\n    }\n\n    let version = user_agent.replace(\"atuin/\", \"\");\n\n    let req = VersionReq::parse(req)?;\n    let version = Version::parse(version.as_str())?;\n\n    Ok(req.matches(&version))\n}\n"
  },
  {
    "path": "crates/atuin-server-database/Cargo.toml",
    "content": "[package]\nname = \"atuin-server-database\"\nedition = \"2024\"\ndescription = \"server database library for atuin\"\n\nversion = { workspace = true }\nauthors = { workspace = true }\nlicense = { workspace = true }\nhomepage = { workspace = true }\nrepository = { workspace = true }\n\n[dependencies]\natuin-common = { path = \"../atuin-common\", version = \"18.13.3\" }\n\ntracing = { workspace = true }\ntime = { workspace = true }\neyre = { workspace = true }\nserde = { workspace = true }\nasync-trait = { workspace = true }\nurl = \"2.5.2\"\n"
  },
  {
    "path": "crates/atuin-server-database/src/calendar.rs",
    "content": "// Calendar data\n\nuse serde::{Deserialize, Serialize};\nuse time::Month;\n\npub enum TimePeriod {\n    Year,\n    Month { year: i32 },\n    Day { year: i32, month: Month },\n}\n\n#[derive(Debug, Serialize, Deserialize)]\npub struct TimePeriodInfo {\n    pub count: u64,\n\n    // TODO: Use this for merkle tree magic\n    pub hash: String,\n}\n"
  },
  {
    "path": "crates/atuin-server-database/src/lib.rs",
    "content": "#![forbid(unsafe_code)]\n\npub mod calendar;\npub mod models;\n\nuse std::{\n    collections::HashMap,\n    fmt::{Debug, Display},\n    ops::Range,\n};\n\nuse self::{\n    calendar::{TimePeriod, TimePeriodInfo},\n    models::{History, NewHistory, NewSession, NewUser, Session, User},\n};\nuse async_trait::async_trait;\nuse atuin_common::record::{EncryptedData, HostId, Record, RecordIdx, RecordStatus};\nuse serde::{Deserialize, Serialize};\nuse time::{Date, Duration, Month, OffsetDateTime, Time, UtcOffset};\nuse tracing::instrument;\n\n#[derive(Debug)]\npub enum DbError {\n    NotFound,\n    Other(eyre::Report),\n}\n\nimpl Display for DbError {\n    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n        write!(f, \"{self:?}\")\n    }\n}\n\nimpl<T: std::error::Error + Into<time::error::Error>> From<T> for DbError {\n    fn from(value: T) -> Self {\n        DbError::Other(value.into().into())\n    }\n}\n\nimpl std::error::Error for DbError {}\n\npub type DbResult<T> = Result<T, DbError>;\n\n#[derive(Debug, PartialEq)]\npub enum DbType {\n    Postgres,\n    Sqlite,\n    Unknown,\n}\n\n#[derive(Clone, Deserialize, Serialize)]\npub struct DbSettings {\n    pub db_uri: String,\n    /// Optional URI for read replicas. If set, read-only queries will use this connection.\n    pub read_db_uri: Option<String>,\n}\n\nimpl DbSettings {\n    pub fn db_type(&self) -> DbType {\n        if self.db_uri.starts_with(\"postgres://\") || self.db_uri.starts_with(\"postgresql://\") {\n            DbType::Postgres\n        } else if self.db_uri.starts_with(\"sqlite://\") {\n            DbType::Sqlite\n        } else {\n            DbType::Unknown\n        }\n    }\n}\n\nfn redact_db_uri(uri: &str) -> String {\n    url::Url::parse(uri)\n        .map(|mut url| {\n            let _ = url.set_password(Some(\"****\"));\n            url.to_string()\n        })\n        .unwrap_or_else(|_| uri.to_string())\n}\n\n// Do our best to redact passwords so they're not logged in the event of an error.\nimpl Debug for DbSettings {\n    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n        if self.db_type() == DbType::Postgres {\n            let redacted_uri = redact_db_uri(&self.db_uri);\n            let redacted_read_uri = self.read_db_uri.as_ref().map(|uri| redact_db_uri(uri));\n            f.debug_struct(\"DbSettings\")\n                .field(\"db_uri\", &redacted_uri)\n                .field(\"read_db_uri\", &redacted_read_uri)\n                .finish()\n        } else {\n            f.debug_struct(\"DbSettings\")\n                .field(\"db_uri\", &self.db_uri)\n                .field(\"read_db_uri\", &self.read_db_uri)\n                .finish()\n        }\n    }\n}\n\n#[async_trait]\npub trait Database: Sized + Clone + Send + Sync + 'static {\n    async fn new(settings: &DbSettings) -> DbResult<Self>;\n\n    async fn get_session(&self, token: &str) -> DbResult<Session>;\n    async fn get_session_user(&self, token: &str) -> DbResult<User>;\n    async fn add_session(&self, session: &NewSession) -> DbResult<()>;\n\n    async fn get_user(&self, username: &str) -> DbResult<User>;\n    async fn get_user_session(&self, u: &User) -> DbResult<Session>;\n    async fn add_user(&self, user: &NewUser) -> DbResult<i64>;\n\n    async fn update_user_password(&self, u: &User) -> DbResult<()>;\n\n    async fn count_history(&self, user: &User) -> DbResult<i64>;\n    async fn count_history_cached(&self, user: &User) -> DbResult<i64>;\n\n    async fn delete_user(&self, u: &User) -> DbResult<()>;\n    async fn delete_history(&self, user: &User, id: String) -> DbResult<()>;\n    async fn deleted_history(&self, user: &User) -> DbResult<Vec<String>>;\n    async fn delete_store(&self, user: &User) -> DbResult<()>;\n\n    async fn add_records(&self, user: &User, record: &[Record<EncryptedData>]) -> DbResult<()>;\n    async fn next_records(\n        &self,\n        user: &User,\n        host: HostId,\n        tag: String,\n        start: Option<RecordIdx>,\n        count: u64,\n    ) -> DbResult<Vec<Record<EncryptedData>>>;\n\n    // Return the tail record ID for each store, so (HostID, Tag, TailRecordID)\n    async fn status(&self, user: &User) -> DbResult<RecordStatus>;\n\n    async fn count_history_range(&self, user: &User, range: Range<OffsetDateTime>)\n    -> DbResult<i64>;\n\n    async fn list_history(\n        &self,\n        user: &User,\n        created_after: OffsetDateTime,\n        since: OffsetDateTime,\n        host: &str,\n        page_size: i64,\n    ) -> DbResult<Vec<History>>;\n\n    async fn add_history(&self, history: &[NewHistory]) -> DbResult<()>;\n\n    async fn oldest_history(&self, user: &User) -> DbResult<History>;\n\n    #[instrument(skip_all)]\n    async fn calendar(\n        &self,\n        user: &User,\n        period: TimePeriod,\n        tz: UtcOffset,\n    ) -> DbResult<HashMap<u64, TimePeriodInfo>> {\n        let mut ret = HashMap::new();\n        let iter: Box<dyn Iterator<Item = DbResult<(u64, Range<Date>)>> + Send> = match period {\n            TimePeriod::Year => {\n                // First we need to work out how far back to calculate. Get the\n                // oldest history item\n                let oldest = self\n                    .oldest_history(user)\n                    .await?\n                    .timestamp\n                    .to_offset(tz)\n                    .year();\n                let current_year = OffsetDateTime::now_utc().to_offset(tz).year();\n\n                // All the years we need to get data for\n                // The upper bound is exclusive, so include current +1\n                let years = oldest..current_year + 1;\n\n                Box::new(years.map(|year| {\n                    let start = Date::from_calendar_date(year, time::Month::January, 1)?;\n                    let end = Date::from_calendar_date(year + 1, time::Month::January, 1)?;\n\n                    Ok((year as u64, start..end))\n                }))\n            }\n\n            TimePeriod::Month { year } => {\n                let months =\n                    std::iter::successors(Some(Month::January), |m| Some(m.next())).take(12);\n\n                Box::new(months.map(move |month| {\n                    let start = Date::from_calendar_date(year, month, 1)?;\n                    let days = start.month().length(year);\n                    let end = start + Duration::days(days as i64);\n\n                    Ok((month as u64, start..end))\n                }))\n            }\n\n            TimePeriod::Day { year, month } => {\n                let days = 1..month.length(year);\n                Box::new(days.map(move |day| {\n                    let start = Date::from_calendar_date(year, month, day)?;\n                    let end = start\n                        .next_day()\n                        .ok_or_else(|| DbError::Other(eyre::eyre!(\"no next day?\")))?;\n\n                    Ok((day as u64, start..end))\n                }))\n            }\n        };\n\n        for x in iter {\n            let (index, range) = x?;\n\n            let start = range.start.with_time(Time::MIDNIGHT).assume_offset(tz);\n            let end = range.end.with_time(Time::MIDNIGHT).assume_offset(tz);\n\n            let count = self.count_history_range(user, start..end).await?;\n\n            ret.insert(\n                index,\n                TimePeriodInfo {\n                    count: count as u64,\n                    hash: \"\".to_string(),\n                },\n            );\n        }\n\n        Ok(ret)\n    }\n}\n"
  },
  {
    "path": "crates/atuin-server-database/src/models.rs",
    "content": "use time::OffsetDateTime;\n\npub struct History {\n    pub id: i64,\n    pub client_id: String, // a client generated ID\n    pub user_id: i64,\n    pub hostname: String,\n    pub timestamp: OffsetDateTime,\n\n    /// All the data we have about this command, encrypted.\n    ///\n    /// Currently this is an encrypted msgpack object, but this may change in the future.\n    pub data: String,\n\n    pub created_at: OffsetDateTime,\n}\n\npub struct NewHistory {\n    pub client_id: String,\n    pub user_id: i64,\n    pub hostname: String,\n    pub timestamp: OffsetDateTime,\n\n    /// All the data we have about this command, encrypted.\n    ///\n    /// Currently this is an encrypted msgpack object, but this may change in the future.\n    pub data: String,\n}\n\npub struct User {\n    pub id: i64,\n    pub username: String,\n    pub email: String,\n    pub password: String,\n}\n\npub struct Session {\n    pub id: i64,\n    pub user_id: i64,\n    pub token: String,\n}\n\npub struct NewUser {\n    pub username: String,\n    pub email: String,\n    pub password: String,\n}\n\npub struct NewSession {\n    pub user_id: i64,\n    pub token: String,\n}\n"
  },
  {
    "path": "crates/atuin-server-postgres/Cargo.toml",
    "content": "[package]\nname = \"atuin-server-postgres\"\nedition = \"2024\"\ndescription = \"server postgres database library for atuin\"\n\nversion = { workspace = true }\nauthors = { workspace = true }\nlicense = { workspace = true }\nhomepage = { workspace = true }\nrepository = { workspace = true }\n\n[dependencies]\natuin-common = { path = \"../atuin-common\", version = \"18.13.3\" }\natuin-server-database = { path = \"../atuin-server-database\", version = \"18.13.3\" }\n\neyre = { workspace = true }\ntracing = { workspace = true }\ntime = { workspace = true }\nserde = { workspace = true }\nsqlx = { workspace = true }\nasync-trait = { workspace = true }\nuuid = { workspace = true }\nmetrics = \"0.24\"\nfutures-util = \"0.3\"\nrand.workspace = true"
  },
  {
    "path": "crates/atuin-server-postgres/build.rs",
    "content": "// generated by `sqlx migrate build-script`\nfn main() {\n    // trigger recompilation when a new migration is added\n    println!(\"cargo:rerun-if-changed=migrations\");\n}\n"
  },
  {
    "path": "crates/atuin-server-postgres/migrations/20210425153745_create_history.sql",
    "content": "create table history (\n\tid bigserial primary key,\n\tclient_id text not null unique, -- the client-generated ID\n\tuser_id bigserial not null,     -- allow multiple users\n\thostname text not null,         -- a unique identifier from the client (can be hashed, random, whatever)\n\ttimestamp timestamp not null,   -- one of the few non-encrypted metadatas\n\n\tdata varchar(8192) not null,    -- store the actual history data, encrypted. I don't wanna know!\n\n\tcreated_at timestamp not null default current_timestamp\n);\n"
  },
  {
    "path": "crates/atuin-server-postgres/migrations/20210425153757_create_users.sql",
    "content": "create table users (\n\tid bigserial primary key,               -- also store our own ID\n\tusername varchar(32) not null unique,   -- being able to contact users is useful\n\temail varchar(128) not null unique,     -- being able to contact users is useful\n\tpassword varchar(128) not null unique\n);\n\n-- the prior index is case sensitive :(\nCREATE UNIQUE INDEX email_unique_idx on users (LOWER(email));\nCREATE UNIQUE INDEX username_unique_idx on users (LOWER(username));\n"
  },
  {
    "path": "crates/atuin-server-postgres/migrations/20210425153800_create_sessions.sql",
    "content": "-- Add migration script here\ncreate table sessions (\n\tid bigserial primary key,\n\tuser_id bigserial,\n\ttoken varchar(128) unique not null\n);\n"
  },
  {
    "path": "crates/atuin-server-postgres/migrations/20220419082412_add_count_trigger.sql",
    "content": "-- Prior to this, the count endpoint was super naive and just ran COUNT(1). \n-- This is slow asf. Now that we have an amount of actual traffic, \n-- stop doing that!\n-- This basically maintains a count, so we can read ONE row, instead of ALL the\n-- rows. Much better.\n-- Future optimisation could use some sort of cache so we don't even need to hit\n-- postgres at all.\n\ncreate table total_history_count_user(\n\tid bigserial primary key, \n\tuser_id bigserial,\n\ttotal integer -- try and avoid using keywords - hence total, not count\n);\n\ncreate or replace function user_history_count()\nreturns trigger as \n$func$\nbegin\n\tif (TG_OP='INSERT') then\n\t\tupdate total_history_count_user set total = total + 1 where user_id = new.user_id;\n\n\t\tif not found then\n\t\t\tinsert into total_history_count_user(user_id, total) \n\t\t\tvalues (\n\t\t\t\tnew.user_id, \n\t\t\t\t(select count(1) from history where user_id = new.user_id)\n\t\t\t);\n\t\tend if;\n\t\t\n\telsif (TG_OP='DELETE') then\n\t\tupdate total_history_count_user set total = total - 1 where user_id = new.user_id;\n\n\t\tif not found then\n\t\t\tinsert into total_history_count_user(user_id, total) \n\t\t\tvalues (\n\t\t\t\tnew.user_id, \n\t\t\t\t(select count(1) from history where user_id = new.user_id)\n\t\t\t);\n\t\tend if;\n\tend if;\n\n\treturn NEW; -- this is actually ignored for an after trigger, but oh well\nend;\n$func$\nlanguage plpgsql volatile -- pldfplplpflh\ncost 100; -- default value\n\ncreate trigger tg_user_history_count \n\tafter insert or delete on history \n\tfor each row \n\texecute procedure user_history_count();\n"
  },
  {
    "path": "crates/atuin-server-postgres/migrations/20220421073605_fix_count_trigger_delete.sql",
    "content": "-- the old version of this function used NEW in the delete part when it should\n-- use OLD\n\ncreate or replace function user_history_count()\nreturns trigger as \n$func$\nbegin\n\tif (TG_OP='INSERT') then\n\t\tupdate total_history_count_user set total = total + 1 where user_id = new.user_id;\n\n\t\tif not found then\n\t\t\tinsert into total_history_count_user(user_id, total) \n\t\t\tvalues (\n\t\t\t\tnew.user_id, \n\t\t\t\t(select count(1) from history where user_id = new.user_id)\n\t\t\t);\n\t\tend if;\n\t\t\n\telsif (TG_OP='DELETE') then\n\t\tupdate total_history_count_user set total = total - 1 where user_id = old.user_id;\n\n\t\tif not found then\n\t\t\tinsert into total_history_count_user(user_id, total) \n\t\t\tvalues (\n\t\t\t\told.user_id, \n\t\t\t\t(select count(1) from history where user_id = old.user_id)\n\t\t\t);\n\t\tend if;\n\tend if;\n\n\treturn NEW; -- this is actually ignored for an after trigger, but oh well\nend;\n$func$\nlanguage plpgsql volatile -- pldfplplpflh\ncost 100; -- default value\n"
  },
  {
    "path": "crates/atuin-server-postgres/migrations/20220421174016_larger-commands.sql",
    "content": "-- Make it 4x larger. Most commands are less than this, but as it's base64\n-- SOME are more than 8192. Should be enough for now.\nALTER TABLE history ALTER COLUMN data TYPE varchar(32768);\n"
  },
  {
    "path": "crates/atuin-server-postgres/migrations/20220426172813_user-created-at.sql",
    "content": "alter table users add column created_at timestamp not null default now();\n"
  },
  {
    "path": "crates/atuin-server-postgres/migrations/20220505082442_create-events.sql",
    "content": "create type event_type as enum ('create', 'delete');\n \ncreate table events (\n\tid bigserial primary key,\n\tclient_id text not null unique, -- the client-generated ID\n\tuser_id bigserial not null,     -- allow multiple users\n\thostname text not null,         -- a unique identifier from the client (can be hashed, random, whatever)\n\ttimestamp timestamp not null,   -- one of the few non-encrypted metadatas\n\n\tevent_type event_type,\n\tdata text not null,    -- store the actual history data, encrypted. I don't wanna know!\n\n\tcreated_at timestamp not null default current_timestamp\n);\n"
  },
  {
    "path": "crates/atuin-server-postgres/migrations/20220610074049_history-length.sql",
    "content": "-- Add migration script here\nalter table history alter column data type text;\n"
  },
  {
    "path": "crates/atuin-server-postgres/migrations/20230315220537_drop-events.sql",
    "content": "-- Add migration script here\ndrop table events;\n"
  },
  {
    "path": "crates/atuin-server-postgres/migrations/20230315224203_create-deleted.sql",
    "content": "-- Add migration script here\nalter table history add column if not exists deleted_at timestamp;\n\n-- queries will all be selecting the ids of history for a user, that has been deleted\ncreate index if not exists history_deleted_index on history(client_id, user_id, deleted_at);\n"
  },
  {
    "path": "crates/atuin-server-postgres/migrations/20230515221038_trigger-delete-only.sql",
    "content": "-- We do not need to run the trigger on deletes, as the only time we are deleting history is when the user\n-- has already been deleted\n-- This actually slows down deleting all the history a good bit!\n\ncreate or replace function user_history_count()\nreturns trigger as \n$func$\nbegin\n\tif (TG_OP='INSERT') then\n\t\tupdate total_history_count_user set total = total + 1 where user_id = new.user_id;\n\n\t\tif not found then\n\t\t\tinsert into total_history_count_user(user_id, total) \n\t\t\tvalues (\n\t\t\t\tnew.user_id, \n\t\t\t\t(select count(1) from history where user_id = new.user_id)\n\t\t\t);\n\t\tend if;\n\tend if;\n\n\treturn NEW; -- this is actually ignored for an after trigger, but oh well\nend;\n$func$\nlanguage plpgsql volatile -- pldfplplpflh\ncost 100; -- default value\n\ncreate or replace trigger tg_user_history_count \n\tafter insert on history \n\tfor each row \n\texecute procedure user_history_count();\n"
  },
  {
    "path": "crates/atuin-server-postgres/migrations/20230623070418_records.sql",
    "content": "-- Add migration script here\ncreate table records (\n\tid uuid primary key,            -- remember to use uuidv7 for happy indices <3\n    client_id uuid not null,        -- I am too uncomfortable with the idea of a client-generated primary key\n\thost uuid not null,             -- a unique identifier for the host\n\tparent uuid default null,       -- the ID of the parent record, bearing in mind this is a linked list\n\ttimestamp bigint not null,      -- not a timestamp type, as those do not have nanosecond precision\n\tversion text not null,\n\ttag text not null,              -- what is this? history, kv, whatever. Remember clients get a log per tag per host\n\tdata text not null,            -- store the actual history data, encrypted. I don't wanna know!\n\tcek text not null,            \n\n\tuser_id bigint not null,        -- allow multiple users\n\tcreated_at timestamp not null default current_timestamp\n);\n"
  },
  {
    "path": "crates/atuin-server-postgres/migrations/20231202170508_create-store.sql",
    "content": "-- Add migration script here\ncreate table store (\n\tid uuid primary key,            -- remember to use uuidv7 for happy indices <3\n  client_id uuid not null,        -- I am too uncomfortable with the idea of a client-generated primary key, even though it's fine mathematically\n\thost uuid not null,             -- a unique identifier for the host\n\tidx bigint not null,       -- the index of the record in this store, identified by (host, tag)\n\ttimestamp bigint not null,      -- not a timestamp type, as those do not have nanosecond precision\n\tversion text not null,\n\ttag text not null,              -- what is this? history, kv, whatever. Remember clients get a log per tag per host\n\tdata text not null,            -- store the actual history data, encrypted. I don't wanna know!\n\tcek text not null,            \n\n\tuser_id bigint not null,        -- allow multiple users\n\tcreated_at timestamp not null default current_timestamp\n);\n"
  },
  {
    "path": "crates/atuin-server-postgres/migrations/20231203124112_create-store-idx.sql",
    "content": "-- Add migration script here\ncreate unique index record_uniq ON store(user_id, host, tag, idx);\n"
  },
  {
    "path": "crates/atuin-server-postgres/migrations/20240108124837_drop-some-defaults.sql",
    "content": "-- Add migration script here\nalter table history alter column user_id drop default;\nalter table sessions alter column user_id drop default;\nalter table total_history_count_user alter column user_id drop default;\n"
  },
  {
    "path": "crates/atuin-server-postgres/migrations/20240614104159_idx-cache.sql",
    "content": "create table store_idx_cache(\n  id bigserial primary key, \n  user_id bigint,\n\n  host uuid,\n  tag text,\n  idx bigint\n);\n"
  },
  {
    "path": "crates/atuin-server-postgres/migrations/20240621110731_user-verified.sql",
    "content": "alter table users add verified_at timestamp with time zone default null;\n\ncreate table user_verification_token(\n  id bigserial primary key, \n  user_id bigint unique references users(id), \n  token text, \n  valid_until timestamp with time zone\n);\n"
  },
  {
    "path": "crates/atuin-server-postgres/migrations/20240702094825_idx_cache_index.sql",
    "content": "create unique index store_idx_cache_uniq on store_idx_cache(user_id, host, tag);\n"
  },
  {
    "path": "crates/atuin-server-postgres/migrations/20260127000000_remove-email-verification.sql",
    "content": "drop table if exists user_verification_token;\nalter table users drop column if exists verified_at;\n"
  },
  {
    "path": "crates/atuin-server-postgres/src/lib.rs",
    "content": "use std::collections::HashMap;\nuse std::ops::Range;\n\nuse rand::Rng;\n\nuse async_trait::async_trait;\nuse atuin_common::record::{EncryptedData, HostId, Record, RecordIdx, RecordStatus};\nuse atuin_server_database::models::{History, NewHistory, NewSession, NewUser, Session, User};\nuse atuin_server_database::{Database, DbError, DbResult, DbSettings};\nuse futures_util::TryStreamExt;\nuse sqlx::Row;\nuse sqlx::postgres::PgPoolOptions;\n\nuse time::{OffsetDateTime, PrimitiveDateTime, UtcOffset};\nuse tracing::instrument;\nuse uuid::Uuid;\nuse wrappers::{DbHistory, DbRecord, DbSession, DbUser};\n\nmod wrappers;\n\nconst MIN_PG_VERSION: u32 = 14;\n\n#[derive(Clone)]\npub struct Postgres {\n    pool: sqlx::Pool<sqlx::postgres::Postgres>,\n    /// Optional read replica pool for read-only queries\n    read_pool: Option<sqlx::Pool<sqlx::postgres::Postgres>>,\n}\n\nimpl Postgres {\n    /// Returns the appropriate pool for read operations.\n    /// Uses read_pool if available, otherwise falls back to the primary pool.\n    fn read_pool(&self) -> &sqlx::Pool<sqlx::postgres::Postgres> {\n        self.read_pool.as_ref().unwrap_or(&self.pool)\n    }\n}\n\nfn fix_error(error: sqlx::Error) -> DbError {\n    match error {\n        sqlx::Error::RowNotFound => DbError::NotFound,\n        error => DbError::Other(error.into()),\n    }\n}\n\n#[async_trait]\nimpl Database for Postgres {\n    async fn new(settings: &DbSettings) -> DbResult<Self> {\n        let pool = PgPoolOptions::new()\n            .max_connections(100)\n            .connect(settings.db_uri.as_str())\n            .await\n            .map_err(fix_error)?;\n\n        // Call server_version_num to get the DB server's major version number\n        // The call returns None for servers older than 8.x.\n        let pg_major_version: u32 = pool\n            .acquire()\n            .await\n            .map_err(fix_error)?\n            .server_version_num()\n            .ok_or(DbError::Other(eyre::Report::msg(\n                \"could not get PostgreSQL version\",\n            )))?\n            / 10000;\n\n        if pg_major_version < MIN_PG_VERSION {\n            return Err(DbError::Other(eyre::Report::msg(format!(\n                \"unsupported PostgreSQL version {pg_major_version}, minimum required is {MIN_PG_VERSION}\"\n            ))));\n        }\n\n        sqlx::migrate!(\"./migrations\")\n            .run(&pool)\n            .await\n            .map_err(|error| DbError::Other(error.into()))?;\n\n        // Create read replica pool if configured\n        let read_pool = if let Some(read_db_uri) = &settings.read_db_uri {\n            tracing::info!(\"Connecting to read replica database\");\n            let read_pool = PgPoolOptions::new()\n                .max_connections(100)\n                .connect(read_db_uri.as_str())\n                .await\n                .map_err(fix_error)?;\n\n            // Verify the read replica is also a supported PostgreSQL version\n            let read_pg_major_version: u32 = read_pool\n                .acquire()\n                .await\n                .map_err(fix_error)?\n                .server_version_num()\n                .ok_or(DbError::Other(eyre::Report::msg(\n                    \"could not get PostgreSQL version from read replica\",\n                )))?\n                / 10000;\n\n            if read_pg_major_version < MIN_PG_VERSION {\n                return Err(DbError::Other(eyre::Report::msg(format!(\n                    \"unsupported PostgreSQL version {read_pg_major_version} on read replica, minimum required is {MIN_PG_VERSION}\"\n                ))));\n            }\n\n            Some(read_pool)\n        } else {\n            None\n        };\n\n        Ok(Self { pool, read_pool })\n    }\n\n    #[instrument(skip_all)]\n    async fn get_session(&self, token: &str) -> DbResult<Session> {\n        sqlx::query_as(\"select id, user_id, token from sessions where token = $1\")\n            .bind(token)\n            .fetch_one(self.read_pool())\n            .await\n            .map_err(fix_error)\n            .map(|DbSession(session)| session)\n    }\n\n    #[instrument(skip_all)]\n    async fn get_user(&self, username: &str) -> DbResult<User> {\n        sqlx::query_as(\"select id, username, email, password from users where username = $1\")\n            .bind(username)\n            .fetch_one(self.read_pool())\n            .await\n            .map_err(fix_error)\n            .map(|DbUser(user)| user)\n    }\n\n    #[instrument(skip_all)]\n    async fn get_session_user(&self, token: &str) -> DbResult<User> {\n        sqlx::query_as(\n            \"select users.id, users.username, users.email, users.password from users\n            inner join sessions\n            on users.id = sessions.user_id\n            and sessions.token = $1\",\n        )\n        .bind(token)\n        .fetch_one(self.read_pool())\n        .await\n        .map_err(fix_error)\n        .map(|DbUser(user)| user)\n    }\n\n    #[instrument(skip_all)]\n    async fn count_history(&self, user: &User) -> DbResult<i64> {\n        // The cache is new, and the user might not yet have a cache value.\n        // They will have one as soon as they post up some new history, but handle that\n        // edge case.\n\n        let res: (i64,) = sqlx::query_as(\n            \"select count(1) from history\n            where user_id = $1\",\n        )\n        .bind(user.id)\n        .fetch_one(self.read_pool())\n        .await\n        .map_err(fix_error)?;\n\n        Ok(res.0)\n    }\n\n    #[instrument(skip_all)]\n    async fn count_history_cached(&self, user: &User) -> DbResult<i64> {\n        let res: (i32,) = sqlx::query_as(\n            \"select total from total_history_count_user\n            where user_id = $1\",\n        )\n        .bind(user.id)\n        .fetch_one(self.read_pool())\n        .await\n        .map_err(fix_error)?;\n\n        Ok(res.0 as i64)\n    }\n\n    async fn delete_store(&self, user: &User) -> DbResult<()> {\n        let mut tx = self.pool.begin().await.map_err(fix_error)?;\n\n        sqlx::query(\n            \"delete from store\n            where user_id = $1\",\n        )\n        .bind(user.id)\n        .execute(&mut *tx)\n        .await\n        .map_err(fix_error)?;\n\n        sqlx::query(\n            \"delete from store_idx_cache\n            where user_id = $1\",\n        )\n        .bind(user.id)\n        .execute(&mut *tx)\n        .await\n        .map_err(fix_error)?;\n\n        tx.commit().await.map_err(fix_error)?;\n\n        Ok(())\n    }\n\n    async fn delete_history(&self, user: &User, id: String) -> DbResult<()> {\n        sqlx::query(\n            \"update history\n            set deleted_at = $3\n            where user_id = $1\n            and client_id = $2\n            and deleted_at is null\", // don't just keep setting it\n        )\n        .bind(user.id)\n        .bind(id)\n        .bind(OffsetDateTime::now_utc())\n        .fetch_all(&self.pool)\n        .await\n        .map_err(fix_error)?;\n\n        Ok(())\n    }\n\n    #[instrument(skip_all)]\n    async fn deleted_history(&self, user: &User) -> DbResult<Vec<String>> {\n        // The cache is new, and the user might not yet have a cache value.\n        // They will have one as soon as they post up some new history, but handle that\n        // edge case.\n\n        let res = sqlx::query(\n            \"select client_id from history\n            where user_id = $1\n            and deleted_at is not null\",\n        )\n        .bind(user.id)\n        .fetch_all(self.read_pool())\n        .await\n        .map_err(fix_error)?;\n\n        let res = res\n            .iter()\n            .map(|row| row.get::<String, _>(\"client_id\"))\n            .collect();\n\n        Ok(res)\n    }\n\n    #[instrument(skip_all)]\n    async fn count_history_range(\n        &self,\n        user: &User,\n        range: Range<OffsetDateTime>,\n    ) -> DbResult<i64> {\n        let res: (i64,) = sqlx::query_as(\n            \"select count(1) from history\n            where user_id = $1\n            and timestamp >= $2::date\n            and timestamp < $3::date\",\n        )\n        .bind(user.id)\n        .bind(into_utc(range.start))\n        .bind(into_utc(range.end))\n        .fetch_one(self.read_pool())\n        .await\n        .map_err(fix_error)?;\n\n        Ok(res.0)\n    }\n\n    #[instrument(skip_all)]\n    async fn list_history(\n        &self,\n        user: &User,\n        created_after: OffsetDateTime,\n        since: OffsetDateTime,\n        host: &str,\n        page_size: i64,\n    ) -> DbResult<Vec<History>> {\n        let res = sqlx::query_as(\n            \"select id, client_id, user_id, hostname, timestamp, data, created_at from history\n            where user_id = $1\n            and hostname != $2\n            and created_at >= $3\n            and timestamp >= $4\n            order by timestamp asc\n            limit $5\",\n        )\n        .bind(user.id)\n        .bind(host)\n        .bind(into_utc(created_after))\n        .bind(into_utc(since))\n        .bind(page_size)\n        .fetch(self.read_pool())\n        .map_ok(|DbHistory(h)| h)\n        .try_collect()\n        .await\n        .map_err(fix_error)?;\n\n        Ok(res)\n    }\n\n    #[instrument(skip_all)]\n    async fn add_history(&self, history: &[NewHistory]) -> DbResult<()> {\n        let mut tx = self.pool.begin().await.map_err(fix_error)?;\n\n        for i in history {\n            let client_id: &str = &i.client_id;\n            let hostname: &str = &i.hostname;\n            let data: &str = &i.data;\n\n            sqlx::query(\n                \"insert into history\n                    (client_id, user_id, hostname, timestamp, data) \n                values ($1, $2, $3, $4, $5)\n                on conflict do nothing\n                \",\n            )\n            .bind(client_id)\n            .bind(i.user_id)\n            .bind(hostname)\n            .bind(i.timestamp)\n            .bind(data)\n            .execute(&mut *tx)\n            .await\n            .map_err(fix_error)?;\n        }\n\n        tx.commit().await.map_err(fix_error)?;\n\n        Ok(())\n    }\n\n    #[instrument(skip_all)]\n    async fn delete_user(&self, u: &User) -> DbResult<()> {\n        sqlx::query(\"delete from sessions where user_id = $1\")\n            .bind(u.id)\n            .execute(&self.pool)\n            .await\n            .map_err(fix_error)?;\n\n        sqlx::query(\"delete from history where user_id = $1\")\n            .bind(u.id)\n            .execute(&self.pool)\n            .await\n            .map_err(fix_error)?;\n\n        sqlx::query(\"delete from store where user_id = $1\")\n            .bind(u.id)\n            .execute(&self.pool)\n            .await\n            .map_err(fix_error)?;\n\n        sqlx::query(\"delete from total_history_count_user where user_id = $1\")\n            .bind(u.id)\n            .execute(&self.pool)\n            .await\n            .map_err(fix_error)?;\n\n        sqlx::query(\"delete from users where id = $1\")\n            .bind(u.id)\n            .execute(&self.pool)\n            .await\n            .map_err(fix_error)?;\n\n        Ok(())\n    }\n\n    #[instrument(skip_all)]\n    async fn update_user_password(&self, user: &User) -> DbResult<()> {\n        sqlx::query(\n            \"update users\n            set password = $1\n            where id = $2\",\n        )\n        .bind(&user.password)\n        .bind(user.id)\n        .execute(&self.pool)\n        .await\n        .map_err(fix_error)?;\n\n        Ok(())\n    }\n\n    #[instrument(skip_all)]\n    async fn add_user(&self, user: &NewUser) -> DbResult<i64> {\n        let email: &str = &user.email;\n        let username: &str = &user.username;\n        let password: &str = &user.password;\n\n        let res: (i64,) = sqlx::query_as(\n            \"insert into users\n                (username, email, password)\n            values($1, $2, $3)\n            returning id\",\n        )\n        .bind(username)\n        .bind(email)\n        .bind(password)\n        .fetch_one(&self.pool)\n        .await\n        .map_err(fix_error)?;\n\n        Ok(res.0)\n    }\n\n    #[instrument(skip_all)]\n    async fn add_session(&self, session: &NewSession) -> DbResult<()> {\n        let token: &str = &session.token;\n\n        sqlx::query(\n            \"insert into sessions\n                (user_id, token)\n            values($1, $2)\",\n        )\n        .bind(session.user_id)\n        .bind(token)\n        .execute(&self.pool)\n        .await\n        .map_err(fix_error)?;\n\n        Ok(())\n    }\n\n    #[instrument(skip_all)]\n    async fn get_user_session(&self, u: &User) -> DbResult<Session> {\n        sqlx::query_as(\"select id, user_id, token from sessions where user_id = $1\")\n            .bind(u.id)\n            .fetch_one(self.read_pool())\n            .await\n            .map_err(fix_error)\n            .map(|DbSession(session)| session)\n    }\n\n    #[instrument(skip_all)]\n    async fn oldest_history(&self, user: &User) -> DbResult<History> {\n        sqlx::query_as(\n            \"select id, client_id, user_id, hostname, timestamp, data, created_at from history\n            where user_id = $1\n            order by timestamp asc\n            limit 1\",\n        )\n        .bind(user.id)\n        .fetch_one(self.read_pool())\n        .await\n        .map_err(fix_error)\n        .map(|DbHistory(h)| h)\n    }\n\n    #[instrument(skip_all)]\n    async fn add_records(&self, user: &User, records: &[Record<EncryptedData>]) -> DbResult<()> {\n        let mut tx = self.pool.begin().await.map_err(fix_error)?;\n\n        // We won't have uploaded this data if it wasn't the max. Therefore, we can deduce the max\n        // idx without having to make further database queries. Doing the query on this small\n        // amount of data should be much, much faster.\n        //\n        // Worst case, say we get this wrong. We end up caching data that isn't actually the max\n        // idx, so clients upload again. The cache logic can be verified with a sql query anyway :)\n\n        let mut heads = HashMap::<(HostId, &str), u64>::new();\n\n        for i in records {\n            let id = atuin_common::utils::uuid_v7();\n\n            let result = sqlx::query(\n                \"insert into store\n                    (id, client_id, host, idx, timestamp, version, tag, data, cek, user_id) \n                values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)\n                on conflict do nothing\n                \",\n            )\n            .bind(id)\n            .bind(i.id)\n            .bind(i.host.id)\n            .bind(i.idx as i64)\n            .bind(i.timestamp as i64) // throwing away some data, but i64 is still big in terms of time\n            .bind(&i.version)\n            .bind(&i.tag)\n            .bind(&i.data.data)\n            .bind(&i.data.content_encryption_key)\n            .bind(user.id)\n            .execute(&mut *tx)\n            .await\n            .map_err(fix_error)?;\n\n            // Only update heads if we actually inserted the record\n            if result.rows_affected() > 0 {\n                heads\n                    .entry((i.host.id, &i.tag))\n                    .and_modify(|e| {\n                        if i.idx > *e {\n                            *e = i.idx\n                        }\n                    })\n                    .or_insert(i.idx);\n            }\n        }\n\n        // we've built the map of heads for this push, so commit it to the database\n        for ((host, tag), idx) in heads {\n            sqlx::query(\n                \"insert into store_idx_cache\n                    (user_id, host, tag, idx) \n                values ($1, $2, $3, $4)\n                on conflict(user_id, host, tag) do update set idx = greatest(store_idx_cache.idx, $4)\n                \",\n            )\n            .bind(user.id)\n            .bind(host)\n            .bind(tag)\n            .bind(idx as i64)\n            .execute(&mut *tx)\n            .await\n            .map_err(fix_error)?;\n        }\n\n        tx.commit().await.map_err(fix_error)?;\n\n        Ok(())\n    }\n\n    #[instrument(skip_all)]\n    async fn next_records(\n        &self,\n        user: &User,\n        host: HostId,\n        tag: String,\n        start: Option<RecordIdx>,\n        count: u64,\n    ) -> DbResult<Vec<Record<EncryptedData>>> {\n        tracing::debug!(\"{:?} - {:?} - {:?}\", host, tag, start);\n        let start = start.unwrap_or(0);\n\n        let records: Result<Vec<DbRecord>, DbError> = sqlx::query_as(\n            \"select client_id, host, idx, timestamp, version, tag, data, cek from store\n                    where user_id = $1\n                    and tag = $2\n                    and host = $3\n                    and idx >= $4\n                    order by idx asc\n                    limit $5\",\n        )\n        .bind(user.id)\n        .bind(tag.clone())\n        .bind(host)\n        .bind(start as i64)\n        .bind(count as i64)\n        .fetch_all(self.read_pool())\n        .await\n        .map_err(fix_error);\n\n        let ret = match records {\n            Ok(records) => {\n                let records: Vec<Record<EncryptedData>> = records\n                    .into_iter()\n                    .map(|f| {\n                        let record: Record<EncryptedData> = f.into();\n                        record\n                    })\n                    .collect();\n\n                records\n            }\n            Err(DbError::NotFound) => {\n                tracing::debug!(\"no records found in store: {:?}/{}\", host, tag);\n                return Ok(vec![]);\n            }\n            Err(e) => return Err(e),\n        };\n\n        Ok(ret)\n    }\n\n    async fn status(&self, user: &User) -> DbResult<RecordStatus> {\n        const STATUS_SQL: &str =\n            \"select host, tag, max(idx) from store where user_id = $1 group by host, tag\";\n\n        // If IDX_CACHE_ROLLOUT is set, then we\n        // 1. Read the value of the var, use it as a % chance of using the cache\n        // 2. If we use the cache, just read from the cache table\n        // 3. If we don't use the cache, read from the store table\n        // IDX_CACHE_ROLLOUT should be between 0 and 100.\n\n        let idx_cache_rollout = std::env::var(\"IDX_CACHE_ROLLOUT\").unwrap_or(\"0\".to_string());\n        let idx_cache_rollout = idx_cache_rollout.parse::<f64>().unwrap_or(0.0);\n        let use_idx_cache = rand::thread_rng().gen_bool(idx_cache_rollout / 100.0);\n\n        let mut res: Vec<(Uuid, String, i64)> = if use_idx_cache {\n            tracing::debug!(\"using idx cache for user {}\", user.id);\n            sqlx::query_as(\"select host, tag, idx from store_idx_cache where user_id = $1\")\n                .bind(user.id)\n                .fetch_all(self.read_pool())\n                .await\n                .map_err(fix_error)?\n        } else {\n            tracing::debug!(\"using aggregate query for user {}\", user.id);\n            sqlx::query_as(STATUS_SQL)\n                .bind(user.id)\n                .fetch_all(self.read_pool())\n                .await\n                .map_err(fix_error)?\n        };\n\n        res.sort();\n\n        let mut status = RecordStatus::new();\n\n        for i in res.iter() {\n            status.set_raw(HostId(i.0), i.1.clone(), i.2 as u64);\n        }\n\n        Ok(status)\n    }\n}\n\nfn into_utc(x: OffsetDateTime) -> PrimitiveDateTime {\n    let x = x.to_offset(UtcOffset::UTC);\n    PrimitiveDateTime::new(x.date(), x.time())\n}\n\n#[cfg(test)]\nmod tests {\n    use time::macros::datetime;\n\n    use crate::into_utc;\n\n    #[test]\n    fn utc() {\n        let dt = datetime!(2023-09-26 15:11:02 +05:30);\n        assert_eq!(into_utc(dt), datetime!(2023-09-26 09:41:02));\n        assert_eq!(into_utc(dt).assume_utc(), dt);\n\n        let dt = datetime!(2023-09-26 15:11:02 -07:00);\n        assert_eq!(into_utc(dt), datetime!(2023-09-26 22:11:02));\n        assert_eq!(into_utc(dt).assume_utc(), dt);\n\n        let dt = datetime!(2023-09-26 15:11:02 +00:00);\n        assert_eq!(into_utc(dt), datetime!(2023-09-26 15:11:02));\n        assert_eq!(into_utc(dt).assume_utc(), dt);\n    }\n}\n"
  },
  {
    "path": "crates/atuin-server-postgres/src/wrappers.rs",
    "content": "use ::sqlx::{FromRow, Result};\nuse atuin_common::record::{EncryptedData, Host, Record};\nuse atuin_server_database::models::{History, Session, User};\nuse sqlx::{Row, postgres::PgRow};\nuse time::PrimitiveDateTime;\n\npub struct DbUser(pub User);\npub struct DbSession(pub Session);\npub struct DbHistory(pub History);\npub struct DbRecord(pub Record<EncryptedData>);\n\nimpl<'a> FromRow<'a, PgRow> for DbUser {\n    fn from_row(row: &'a PgRow) -> Result<Self> {\n        Ok(Self(User {\n            id: row.try_get(\"id\")?,\n            username: row.try_get(\"username\")?,\n            email: row.try_get(\"email\")?,\n            password: row.try_get(\"password\")?,\n        }))\n    }\n}\n\nimpl<'a> ::sqlx::FromRow<'a, PgRow> for DbSession {\n    fn from_row(row: &'a PgRow) -> ::sqlx::Result<Self> {\n        Ok(Self(Session {\n            id: row.try_get(\"id\")?,\n            user_id: row.try_get(\"user_id\")?,\n            token: row.try_get(\"token\")?,\n        }))\n    }\n}\n\nimpl<'a> ::sqlx::FromRow<'a, PgRow> for DbHistory {\n    fn from_row(row: &'a PgRow) -> ::sqlx::Result<Self> {\n        Ok(Self(History {\n            id: row.try_get(\"id\")?,\n            client_id: row.try_get(\"client_id\")?,\n            user_id: row.try_get(\"user_id\")?,\n            hostname: row.try_get(\"hostname\")?,\n            timestamp: row\n                .try_get::<PrimitiveDateTime, _>(\"timestamp\")?\n                .assume_utc(),\n            data: row.try_get(\"data\")?,\n            created_at: row\n                .try_get::<PrimitiveDateTime, _>(\"created_at\")?\n                .assume_utc(),\n        }))\n    }\n}\n\nimpl<'a> ::sqlx::FromRow<'a, PgRow> for DbRecord {\n    fn from_row(row: &'a PgRow) -> ::sqlx::Result<Self> {\n        let timestamp: i64 = row.try_get(\"timestamp\")?;\n        let idx: i64 = row.try_get(\"idx\")?;\n\n        let data = EncryptedData {\n            data: row.try_get(\"data\")?,\n            content_encryption_key: row.try_get(\"cek\")?,\n        };\n\n        Ok(Self(Record {\n            id: row.try_get(\"client_id\")?,\n            host: Host::new(row.try_get(\"host\")?),\n            idx: idx as u64,\n            timestamp: timestamp as u64,\n            version: row.try_get(\"version\")?,\n            tag: row.try_get(\"tag\")?,\n            data,\n        }))\n    }\n}\n\nimpl From<DbRecord> for Record<EncryptedData> {\n    fn from(other: DbRecord) -> Record<EncryptedData> {\n        Record { ..other.0 }\n    }\n}\n"
  },
  {
    "path": "crates/atuin-server-sqlite/Cargo.toml",
    "content": "[package]\nname = \"atuin-server-sqlite\"\nedition = \"2024\"\ndescription = \"server sqlite database library for atuin\"\n\nversion = { workspace = true }\nauthors = { workspace = true }\nlicense = { workspace = true }\nhomepage = { workspace = true }\nrepository = { workspace = true }\n\n[dependencies]\natuin-common = { path = \"../atuin-common\", version = \"18.13.3\" }\natuin-server-database = { path = \"../atuin-server-database\", version = \"18.13.3\" }\n\neyre = { workspace = true }\ntracing = { workspace = true }\ntime = { workspace = true }\nserde = { workspace = true }\nsqlx = { workspace = true, features = [\"sqlite\", \"regexp\"] }\nasync-trait = { workspace = true }\nuuid = { workspace = true }\nmetrics = \"0.24\"\nfutures-util = \"0.3\"\n"
  },
  {
    "path": "crates/atuin-server-sqlite/build.rs",
    "content": "// generated by `sqlx migrate build-script`\nfn main() {\n    // trigger recompilation when a new migration is added\n    println!(\"cargo:rerun-if-changed=migrations\");\n}\n"
  },
  {
    "path": "crates/atuin-server-sqlite/migrations/20231203124112_create-store.sql",
    "content": "create table store (\n\tid text primary key,            -- remember to use uuidv7 for happy indices <3\n  client_id text not null,        -- I am too uncomfortable with the idea of a client-generated primary key, even though it's fine mathematically\n\thost text not null,             -- a unique identifier for the host\n\tidx bigint not null,       -- the index of the record in this store, identified by (host, tag)\n\ttimestamp bigint not null,      -- not a timestamp type, as those do not have nanosecond precision\n\tversion text not null,\n\ttag text not null,              -- what is this? history, kv, whatever. Remember clients get a log per tag per host\n\tdata text not null,            -- store the actual history data, encrypted. I don't wanna know!\n\tcek text not null,            \n\n\tuser_id bigint not null,        -- allow multiple users\n\tcreated_at timestamp not null default current_timestamp\n);\n\ncreate unique index record_uniq ON store(user_id, host, tag, idx);\n\n"
  },
  {
    "path": "crates/atuin-server-sqlite/migrations/20240108124830_create-history.sql",
    "content": "create table history (\n\tid integer primary key autoincrement,\n\tclient_id text not null unique, -- the client-generated ID\n\tuser_id bigserial not null,     -- allow multiple users\n\thostname text not null,         -- a unique identifier from the client (can be hashed, random, whatever)\n\ttimestamp timestamp not null,   -- one of the few non-encrypted metadatas\n\n\tdata text not null,    -- store the actual history data, encrypted. I don't wanna know!\n\n\tcreated_at timestamp not null default current_timestamp,\n  deleted_at timestamp\n);\n\ncreate unique index history_deleted_index on history(client_id, user_id, deleted_at);\n\n"
  },
  {
    "path": "crates/atuin-server-sqlite/migrations/20240108124831_create-sessions.sql",
    "content": "create table sessions (\n\tid integer primary key autoincrement,\n\tuser_id integer,\n\ttoken text unique not null\n);\n\n"
  },
  {
    "path": "crates/atuin-server-sqlite/migrations/20240621110730_create-users.sql",
    "content": "create table users (\n\tid integer primary key autoincrement,               -- also store our own ID\n\tusername text not null unique,   -- being able to contact users is useful\n\temail text not null unique,     -- being able to contact users is useful\n\tpassword text not null unique,\n  created_at timestamp not null default (datetime('now','localtime')),\n  verified_at timestamp with time zone default null\n);\n\n-- the prior index is case sensitive :(\nCREATE UNIQUE INDEX email_unique_idx on users (LOWER(email));\nCREATE UNIQUE INDEX username_unique_idx on users (LOWER(username));\n"
  },
  {
    "path": "crates/atuin-server-sqlite/migrations/20240621110731_create-user-verification-token.sql",
    "content": "create table user_verification_token(\n  id integer primary key autoincrement, \n  user_id bigint unique references users(id), \n  token text, \n  valid_until timestamp with time zone\n);\n"
  },
  {
    "path": "crates/atuin-server-sqlite/migrations/20240702094825_create-store-idx-cache.sql",
    "content": "create table store_idx_cache(\n  id integer primary key autoincrement, \n  user_id bigint,\n\n  host uuid,\n  tag text,\n  idx bigint\n);\n\ncreate unique index store_idx_cache_uniq on store_idx_cache(user_id, host, tag);\n"
  },
  {
    "path": "crates/atuin-server-sqlite/migrations/20260127000000_remove-email-verification.sql",
    "content": "drop table if exists user_verification_token;\nalter table users drop column verified_at;\n"
  },
  {
    "path": "crates/atuin-server-sqlite/src/lib.rs",
    "content": "use std::str::FromStr;\n\nuse async_trait::async_trait;\nuse atuin_common::record::{EncryptedData, HostId, Record, RecordIdx, RecordStatus};\nuse atuin_server_database::{\n    Database, DbError, DbResult, DbSettings,\n    models::{History, NewHistory, NewSession, NewUser, Session, User},\n};\nuse futures_util::TryStreamExt;\nuse sqlx::{\n    Row,\n    sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions},\n    types::Uuid,\n};\nuse time::{OffsetDateTime, PrimitiveDateTime, UtcOffset};\nuse tracing::instrument;\nuse wrappers::{DbHistory, DbRecord, DbSession, DbUser};\n\nmod wrappers;\n\n#[derive(Clone)]\npub struct Sqlite {\n    pool: sqlx::Pool<sqlx::sqlite::Sqlite>,\n}\n\nfn fix_error(error: sqlx::Error) -> DbError {\n    match error {\n        sqlx::Error::RowNotFound => DbError::NotFound,\n        error => DbError::Other(error.into()),\n    }\n}\n\n#[async_trait]\nimpl Database for Sqlite {\n    async fn new(settings: &DbSettings) -> DbResult<Self> {\n        let opts = SqliteConnectOptions::from_str(&settings.db_uri)\n            .map_err(fix_error)?\n            .journal_mode(SqliteJournalMode::Wal)\n            .create_if_missing(true);\n\n        let pool = SqlitePoolOptions::new()\n            .connect_with(opts)\n            .await\n            .map_err(fix_error)?;\n\n        sqlx::migrate!(\"./migrations\")\n            .run(&pool)\n            .await\n            .map_err(|error| DbError::Other(error.into()))?;\n\n        Ok(Self { pool })\n    }\n\n    #[instrument(skip_all)]\n    async fn get_session(&self, token: &str) -> DbResult<Session> {\n        sqlx::query_as(\"select id, user_id, token from sessions where token = $1\")\n            .bind(token)\n            .fetch_one(&self.pool)\n            .await\n            .map_err(fix_error)\n            .map(|DbSession(session)| session)\n    }\n\n    #[instrument(skip_all)]\n    async fn get_session_user(&self, token: &str) -> DbResult<User> {\n        sqlx::query_as(\n            \"select users.id, users.username, users.email, users.password from users\n            inner join sessions\n            on users.id = sessions.user_id\n            and sessions.token = $1\",\n        )\n        .bind(token)\n        .fetch_one(&self.pool)\n        .await\n        .map_err(fix_error)\n        .map(|DbUser(user)| user)\n    }\n\n    #[instrument(skip_all)]\n    async fn add_session(&self, session: &NewSession) -> DbResult<()> {\n        let token: &str = &session.token;\n\n        sqlx::query(\n            \"insert into sessions\n                (user_id, token)\n            values($1, $2)\",\n        )\n        .bind(session.user_id)\n        .bind(token)\n        .execute(&self.pool)\n        .await\n        .map_err(fix_error)?;\n\n        Ok(())\n    }\n\n    #[instrument(skip_all)]\n    async fn get_user(&self, username: &str) -> DbResult<User> {\n        sqlx::query_as(\"select id, username, email, password from users where username = $1\")\n            .bind(username)\n            .fetch_one(&self.pool)\n            .await\n            .map_err(fix_error)\n            .map(|DbUser(user)| user)\n    }\n\n    #[instrument(skip_all)]\n    async fn get_user_session(&self, u: &User) -> DbResult<Session> {\n        sqlx::query_as(\"select id, user_id, token from sessions where user_id = $1\")\n            .bind(u.id)\n            .fetch_one(&self.pool)\n            .await\n            .map_err(fix_error)\n            .map(|DbSession(session)| session)\n    }\n\n    #[instrument(skip_all)]\n    async fn add_user(&self, user: &NewUser) -> DbResult<i64> {\n        let email: &str = &user.email;\n        let username: &str = &user.username;\n        let password: &str = &user.password;\n\n        let res: (i64,) = sqlx::query_as(\n            \"insert into users\n                (username, email, password)\n            values($1, $2, $3)\n            returning id\",\n        )\n        .bind(username)\n        .bind(email)\n        .bind(password)\n        .fetch_one(&self.pool)\n        .await\n        .map_err(fix_error)?;\n\n        Ok(res.0)\n    }\n\n    #[instrument(skip_all)]\n    async fn update_user_password(&self, user: &User) -> DbResult<()> {\n        sqlx::query(\n            \"update users\n            set password = $1\n            where id = $2\",\n        )\n        .bind(&user.password)\n        .bind(user.id)\n        .execute(&self.pool)\n        .await\n        .map_err(fix_error)?;\n\n        Ok(())\n    }\n\n    #[instrument(skip_all)]\n    async fn count_history(&self, user: &User) -> DbResult<i64> {\n        // The cache is new, and the user might not yet have a cache value.\n        // They will have one as soon as they post up some new history, but handle that\n        // edge case.\n\n        let res: (i64,) = sqlx::query_as(\n            \"select count(1) from history\n            where user_id = $1\",\n        )\n        .bind(user.id)\n        .fetch_one(&self.pool)\n        .await\n        .map_err(fix_error)?;\n\n        Ok(res.0)\n    }\n\n    #[instrument(skip_all)]\n    async fn count_history_cached(&self, _user: &User) -> DbResult<i64> {\n        Err(DbError::NotFound)\n    }\n\n    #[instrument(skip_all)]\n    async fn delete_user(&self, u: &User) -> DbResult<()> {\n        sqlx::query(\"delete from sessions where user_id = $1\")\n            .bind(u.id)\n            .execute(&self.pool)\n            .await\n            .map_err(fix_error)?;\n\n        sqlx::query(\"delete from users where id = $1\")\n            .bind(u.id)\n            .execute(&self.pool)\n            .await\n            .map_err(fix_error)?;\n\n        sqlx::query(\"delete from history where user_id = $1\")\n            .bind(u.id)\n            .execute(&self.pool)\n            .await\n            .map_err(fix_error)?;\n\n        Ok(())\n    }\n\n    async fn delete_history(&self, user: &User, id: String) -> DbResult<()> {\n        sqlx::query(\n            \"update history\n            set deleted_at = $3\n            where user_id = $1\n            and client_id = $2\n            and deleted_at is null\", // don't just keep setting it\n        )\n        .bind(user.id)\n        .bind(id)\n        .bind(time::OffsetDateTime::now_utc())\n        .fetch_all(&self.pool)\n        .await\n        .map_err(fix_error)?;\n\n        Ok(())\n    }\n\n    #[instrument(skip_all)]\n    async fn deleted_history(&self, user: &User) -> DbResult<Vec<String>> {\n        // The cache is new, and the user might not yet have a cache value.\n        // They will have one as soon as they post up some new history, but handle that\n        // edge case.\n\n        let res = sqlx::query(\n            \"select client_id from history \n            where user_id = $1\n            and deleted_at is not null\",\n        )\n        .bind(user.id)\n        .fetch_all(&self.pool)\n        .await\n        .map_err(fix_error)?;\n\n        let res = res.iter().map(|row| row.get(\"client_id\")).collect();\n\n        Ok(res)\n    }\n\n    async fn delete_store(&self, user: &User) -> DbResult<()> {\n        sqlx::query(\n            \"delete from store\n            where user_id = $1\",\n        )\n        .bind(user.id)\n        .execute(&self.pool)\n        .await\n        .map_err(fix_error)?;\n\n        Ok(())\n    }\n\n    #[instrument(skip_all)]\n    async fn add_records(&self, user: &User, records: &[Record<EncryptedData>]) -> DbResult<()> {\n        let mut tx = self.pool.begin().await.map_err(fix_error)?;\n\n        for i in records {\n            let id = atuin_common::utils::uuid_v7();\n\n            sqlx::query(\n                \"insert into store\n                    (id, client_id, host, idx, timestamp, version, tag, data, cek, user_id) \n                values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)\n                on conflict do nothing\n                \",\n            )\n            .bind(id)\n            .bind(i.id)\n            .bind(i.host.id)\n            .bind(i.idx as i64)\n            .bind(i.timestamp as i64) // throwing away some data, but i64 is still big in terms of time\n            .bind(&i.version)\n            .bind(&i.tag)\n            .bind(&i.data.data)\n            .bind(&i.data.content_encryption_key)\n            .bind(user.id)\n            .execute(&mut *tx)\n            .await\n            .map_err(fix_error)?;\n        }\n\n        tx.commit().await.map_err(fix_error)?;\n\n        Ok(())\n    }\n\n    #[instrument(skip_all)]\n    async fn next_records(\n        &self,\n        user: &User,\n        host: HostId,\n        tag: String,\n        start: Option<RecordIdx>,\n        count: u64,\n    ) -> DbResult<Vec<Record<EncryptedData>>> {\n        tracing::debug!(\"{:?} - {:?} - {:?}\", host, tag, start);\n        let start = start.unwrap_or(0);\n\n        let records: Result<Vec<DbRecord>, DbError> = sqlx::query_as(\n            \"select client_id, host, idx, timestamp, version, tag, data, cek from store\n                    where user_id = $1\n                    and tag = $2\n                    and host = $3\n                    and idx >= $4\n                    order by idx asc\n                    limit $5\",\n        )\n        .bind(user.id)\n        .bind(tag.clone())\n        .bind(host)\n        .bind(start as i64)\n        .bind(count as i64)\n        .fetch_all(&self.pool)\n        .await\n        .map_err(fix_error);\n\n        let ret = match records {\n            Ok(records) => {\n                let records: Vec<Record<EncryptedData>> = records\n                    .into_iter()\n                    .map(|f| {\n                        let record: Record<EncryptedData> = f.into();\n                        record\n                    })\n                    .collect();\n\n                records\n            }\n            Err(DbError::NotFound) => {\n                tracing::debug!(\"no records found in store: {:?}/{}\", host, tag);\n                return Ok(vec![]);\n            }\n            Err(e) => return Err(e),\n        };\n\n        Ok(ret)\n    }\n\n    async fn status(&self, user: &User) -> DbResult<RecordStatus> {\n        const STATUS_SQL: &str =\n            \"select host, tag, max(idx) from store where user_id = $1 group by host, tag\";\n\n        let res: Vec<(Uuid, String, i64)> = sqlx::query_as(STATUS_SQL)\n            .bind(user.id)\n            .fetch_all(&self.pool)\n            .await\n            .map_err(fix_error)?;\n\n        let mut status = RecordStatus::new();\n\n        for i in res {\n            status.set_raw(HostId(i.0), i.1, i.2 as u64);\n        }\n\n        Ok(status)\n    }\n\n    #[instrument(skip_all)]\n    async fn count_history_range(\n        &self,\n        user: &User,\n        range: std::ops::Range<time::OffsetDateTime>,\n    ) -> DbResult<i64> {\n        let res: (i64,) = sqlx::query_as(\n            \"select count(1) from history\n            where user_id = $1\n            and timestamp >= $2::date\n            and timestamp < $3::date\",\n        )\n        .bind(user.id)\n        .bind(into_utc(range.start))\n        .bind(into_utc(range.end))\n        .fetch_one(&self.pool)\n        .await\n        .map_err(fix_error)?;\n\n        Ok(res.0)\n    }\n\n    #[instrument(skip_all)]\n    async fn list_history(\n        &self,\n        user: &User,\n        created_after: time::OffsetDateTime,\n        since: time::OffsetDateTime,\n        host: &str,\n        page_size: i64,\n    ) -> DbResult<Vec<History>> {\n        let res = sqlx::query_as(\n            \"select id, client_id, user_id, hostname, timestamp, data, created_at from history\n            where user_id = $1\n            and hostname != $2\n            and created_at >= $3\n            and timestamp >= $4\n            order by timestamp asc\n            limit $5\",\n        )\n        .bind(user.id)\n        .bind(host)\n        .bind(into_utc(created_after))\n        .bind(into_utc(since))\n        .bind(page_size)\n        .fetch(&self.pool)\n        .map_ok(|DbHistory(h)| h)\n        .try_collect()\n        .await\n        .map_err(fix_error)?;\n\n        Ok(res)\n    }\n\n    #[instrument(skip_all)]\n    async fn add_history(&self, history: &[NewHistory]) -> DbResult<()> {\n        let mut tx = self.pool.begin().await.map_err(fix_error)?;\n\n        for i in history {\n            let client_id: &str = &i.client_id;\n            let hostname: &str = &i.hostname;\n            let data: &str = &i.data;\n\n            sqlx::query(\n                \"insert into history\n                    (client_id, user_id, hostname, timestamp, data) \n                values ($1, $2, $3, $4, $5)\n                on conflict do nothing\n                \",\n            )\n            .bind(client_id)\n            .bind(i.user_id)\n            .bind(hostname)\n            .bind(i.timestamp)\n            .bind(data)\n            .execute(&mut *tx)\n            .await\n            .map_err(fix_error)?;\n        }\n\n        tx.commit().await.map_err(fix_error)?;\n\n        Ok(())\n    }\n\n    #[instrument(skip_all)]\n    async fn oldest_history(&self, user: &User) -> DbResult<History> {\n        sqlx::query_as(\n            \"select id, client_id, user_id, hostname, timestamp, data, created_at from history \n            where user_id = $1\n            order by timestamp asc\n            limit 1\",\n        )\n        .bind(user.id)\n        .fetch_one(&self.pool)\n        .await\n        .map_err(fix_error)\n        .map(|DbHistory(h)| h)\n    }\n}\n\nfn into_utc(x: OffsetDateTime) -> PrimitiveDateTime {\n    let x = x.to_offset(UtcOffset::UTC);\n    PrimitiveDateTime::new(x.date(), x.time())\n}\n"
  },
  {
    "path": "crates/atuin-server-sqlite/src/wrappers.rs",
    "content": "use ::sqlx::{FromRow, Result};\nuse atuin_common::record::{EncryptedData, Host, Record};\nuse atuin_server_database::models::{History, Session, User};\nuse sqlx::{Row, sqlite::SqliteRow};\n\npub struct DbUser(pub User);\npub struct DbSession(pub Session);\npub struct DbHistory(pub History);\npub struct DbRecord(pub Record<EncryptedData>);\n\nimpl<'a> FromRow<'a, SqliteRow> for DbUser {\n    fn from_row(row: &'a SqliteRow) -> Result<Self> {\n        Ok(Self(User {\n            id: row.try_get(\"id\")?,\n            username: row.try_get(\"username\")?,\n            email: row.try_get(\"email\")?,\n            password: row.try_get(\"password\")?,\n        }))\n    }\n}\n\nimpl<'a> ::sqlx::FromRow<'a, SqliteRow> for DbSession {\n    fn from_row(row: &'a SqliteRow) -> ::sqlx::Result<Self> {\n        Ok(Self(Session {\n            id: row.try_get(\"id\")?,\n            user_id: row.try_get(\"user_id\")?,\n            token: row.try_get(\"token\")?,\n        }))\n    }\n}\n\nimpl<'a> ::sqlx::FromRow<'a, SqliteRow> for DbHistory {\n    fn from_row(row: &'a SqliteRow) -> ::sqlx::Result<Self> {\n        Ok(Self(History {\n            id: row.try_get(\"id\")?,\n            client_id: row.try_get(\"client_id\")?,\n            user_id: row.try_get(\"user_id\")?,\n            hostname: row.try_get(\"hostname\")?,\n            timestamp: row.try_get(\"timestamp\")?,\n            data: row.try_get(\"data\")?,\n            created_at: row.try_get(\"created_at\")?,\n        }))\n    }\n}\n\nimpl<'a> ::sqlx::FromRow<'a, SqliteRow> for DbRecord {\n    fn from_row(row: &'a SqliteRow) -> ::sqlx::Result<Self> {\n        let idx: i64 = row.try_get(\"idx\")?;\n        let timestamp: i64 = row.try_get(\"timestamp\")?;\n\n        let data = EncryptedData {\n            data: row.try_get(\"data\")?,\n            content_encryption_key: row.try_get(\"cek\")?,\n        };\n\n        Ok(Self(Record {\n            id: row.try_get(\"client_id\")?,\n            host: Host::new(row.try_get(\"host\")?),\n            idx: idx as u64,\n            timestamp: timestamp as u64,\n            version: row.try_get(\"version\")?,\n            tag: row.try_get(\"tag\")?,\n            data,\n        }))\n    }\n}\n\nimpl From<DbRecord> for Record<EncryptedData> {\n    fn from(other: DbRecord) -> Record<EncryptedData> {\n        Record { ..other.0 }\n    }\n}\n"
  },
  {
    "path": "default.nix",
    "content": "(import\n  (\n    let lock = builtins.fromJSON (builtins.readFile ./flake.lock); in\n    fetchTarball {\n      url = \"https://github.com/edolstra/flake-compat/archive/${lock.nodes.flake-compat.locked.rev}.tar.gz\";\n      sha256 = lock.nodes.flake-compat.locked.narHash;\n    }\n  )\n  { src = ./.; }\n).defaultNix\n"
  },
  {
    "path": "deny.toml",
    "content": "# This template contains all of the possible sections and their default values\n\n# Note that all fields that take a lint level have these possible values:\n# * deny - An error will be produced and the check will fail\n# * warn - A warning will be produced, but the check will not fail\n# * allow - No warning or error will be produced, though in some cases a note\n# will be\n\n# The values provided in this template are the default values that will be used\n# when any section or field is not specified in your own configuration\n\n# Root options\n\ntargets = []\nall-features = true\nno-default-features = false\n\n# This section is considered when running `cargo deny check advisories`\n# More documentation for the advisories section can be found here:\n# https://embarkstudios.github.io/cargo-deny/checks/advisories/cfg.html\n[advisories]\ndb-path = \"~/.cargo/advisory-db\"\ndb-urls = [\"https://github.com/rustsec/advisory-db\"]\nvulnerability = \"deny\"\nunmaintained = \"warn\"\nyanked = \"warn\"\nnotice = \"warn\"\nignore = [\n    # potential to misuse ed25519-dalek 1.0\n    # used by rusty-paseto. not in a vulnerable way\n    # and we don't even use paseto public key crypto so we don't use this\n    \"RUSTSEC-2022-0093\",\n    # DoS with untrusted input. Only runs on the client so not a concern\n    \"RUSTSEC-2021-0041\",\n]\n\n# This section is considered when running `cargo deny check licenses`\n# More documentation for the licenses section can be found here:\n# https://embarkstudios.github.io/cargo-deny/checks/licenses/cfg.html\n[licenses]\nunlicensed = \"deny\"\nallow = [\n    \"Apache-2.0\",\n    \"BSD-3-Clause\",\n    \"ISC\",\n    \"MIT\",\n    \"MPL-2.0\",\n    \"OpenSSL\",\n    \"Unicode-DFS-2016\",\n]\ndeny = []\ncopyleft = \"warn\"\nallow-osi-fsf-free = \"neither\"\ndefault = \"deny\"\nconfidence-threshold = 0.8\nexceptions = []\n\n# Some crates don't have (easily) machine readable licensing information,\n# adding a clarification entry for it allows you to manually specify the\n# licensing information\n[[licenses.clarify]]\nname = \"ring\"\nversion = \"*\"\nexpression = \"MIT AND ISC AND OpenSSL\"\nlicense-files = [{ path = \"LICENSE\", hash = 0xbd0eed23 }]\n\n# This section is considered when running `cargo deny check bans`.\n# More documentation about the 'bans' section can be found here:\n# https://embarkstudios.github.io/cargo-deny/checks/bans/cfg.html\n[bans]\nmultiple-versions = \"allow\"\nwildcards = \"warn\"\nhighlight = \"all\"\nworkspace-default-features = \"allow\"\nexternal-default-features = \"allow\"\nallow = []\ndeny = []\nskip = []\nskip-tree = []\n\n# This section is considered when running `cargo deny check sources`.\n# More documentation about the 'sources' section can be found here:\n# https://embarkstudios.github.io/cargo-deny/checks/sources/cfg.html\n[sources]\n# Lint level for what to happen when a crate from a crate registry that is not\n# in the allow list is encountered\nunknown-registry = \"warn\"\n# Lint level for what to happen when a crate from a git repository that is not\n# in the allow list is encountered\nunknown-git = \"warn\"\n# List of URLs for allowed crate registries. Defaults to the crates.io index\n# if not specified. If it is specified but empty, no registries are allowed.\nallow-registry = [\"https://github.com/rust-lang/crates.io-index\"]\n# List of URLs for allowed Git repositories\nallow-git = []\n\n[sources.allow-org]\n# 1 or more github.com organizations to allow git sources for\ngithub = []\n# 1 or more gitlab.com organizations to allow git sources for\ngitlab = []\n# 1 or more bitbucket.org organizations to allow git sources for\nbitbucket = []\n"
  },
  {
    "path": "depot.json",
    "content": "{\"id\":\"v6vqpk6559\"}\n"
  },
  {
    "path": "dist-workspace.toml",
    "content": "[workspace]\nmembers = [\"cargo:.\"]\n\n# Config for 'dist'\n[dist]\n# Path that installers should place binaries in\ninstall-path = \"~/.atuin/bin\"\n# The preferred dist version to use in CI (Cargo.toml SemVer syntax)\ncargo-dist-version = \"0.31.0\"\n# CI backends to support\nci = \"github\"\n# The installers to generate for each app\ninstallers = [\"shell\", \"powershell\"]\n# Target platforms to build apps for (Rust target-triple syntax)\ntargets = [\"aarch64-apple-darwin\", \"aarch64-unknown-linux-gnu\", \"aarch64-unknown-linux-musl\", \"x86_64-unknown-linux-gnu\", \"x86_64-unknown-linux-musl\", \"x86_64-pc-windows-msvc\"]\n# Which actions to run on pull requests\npr-run-mode = \"plan\"\n# Whether to install an updater program\ninstall-updater = true\n# The archive format to use for non-windows builds (defaults .tar.xz)\nunix-archive = \".tar.gz\"\n# Whether to enable GitHub Attestations\ngithub-attestations = true\n\n[dist.github-custom-runners]\naarch64-unknown-linux-gnu = \"depot-ubuntu-24.04-arm-8\"\naarch64-unknown-linux-musl = \"depot-ubuntu-24.04-arm-8\"\nx86_64-unknown-linux-gnu = \"depot-ubuntu-24.04-8\"\nx86_64-unknown-linux-musl = \"depot-ubuntu-24.04-8\"\n"
  },
  {
    "path": "docker-compose.yml",
    "content": "services:\n  atuin:\n    restart: always\n    image: ghcr.io/atuinsh/atuin:<LATEST TAGGED RELEASE>\n    command: start\n    volumes:\n      - \"./config:/config\"\n    ports:\n      - 8888:8888\n    environment:\n      ATUIN_HOST: \"0.0.0.0\"\n      ATUIN_OPEN_REGISTRATION: \"true\"\n      ATUIN_DB_URI: postgres://{$ATUIN_DB_USERNAME}:${ATUIN_DB_PASSWORD}@db/${ATUIN_DB_NAME}\n      RUST_LOG: info,atuin_server=debug\n    depends_on:\n      - db\n  db:\n    image: postgres:14\n    restart: unless-stopped\n    volumes: # Don't remove permanent storage for index database files!\n      - \"./database:/var/lib/postgresql/data/\"\n    environment:\n      POSTGRES_USER: ${ATUIN_DB_USERNAME}\n      POSTGRES_PASSWORD: ${ATUIN_DB_PASSWORD}\n      POSTGRES_DB: ${ATUIN_DB_NAME}\n"
  },
  {
    "path": "docs/.gitignore",
    "content": "site/\n"
  },
  {
    "path": "docs/docs/ai/introduction.md",
    "content": "# Atuin AI\n\nAtuin AI is a subcommand that enables shell command generation and other information lookup via an LLM directly from your terminal.\n\nAtuin AI requires an account on [Atuin Hub](https://hub.atuin.sh/), and you'll be prompted to login upon first use of the binary.\n\n## Getting Started\n\nAtuin AI currently supports zsh, bash, and fish shells. Your shell's usual `atuin init` call will automatically bind the question mark key to the Atuin AI UI (only when the prompt is empty).\n\n!!! note \"Disabling Atuin AI\"\n\n    You can disable the default question mark key binding by passing `--disable-ai` to your shell's `atuin init` call.\n\n## Settings\n\nFor a list of settings that control the behavior of Atuin AI, see [its dedicated settings documentation](./settings.md).\n\n## Features\n\n### Command generation\n\nPrompt the LLM to create a command, and get one back, no fuss. Press `enter` to run, or `tab` to insert.\n\n```\n┌Ask questions or generate a command:──────────────────────────┐\n│                                                              │\n│ > Get a list of running docker containers                    │\n│                                                              │\n├──────────────────────────────────────────────────────────────┤\n│                                                              │\n│ $ docker ps                                                  │\n│                                                              │\n└────[Enter]: Run  [Tab]: Insert  [f]: Follow-up  [Esc]: Cancel┘\n```\n\n### Follow-up\n\nYou can follow-up with `f` to specify a refinement prompt to update the command that will be inserted.\n\n```\n┌Ask questions or generate a command:──────────────────────────┐\n│                                                              │\n│ > Get a list of running docker containers                    │\n│                                                              │\n├──────────────────────────────────────────────────────────────┤\n│                                                              │\n│ $ docker ps                                                  │\n│                                                              │\n├──────────────────────────────────────────────────────────────┤\n│                                                              │\n│ > Actually I want to get all docker containers               │\n│                                                              │\n├──────────────────────────────────────────────────────────────┤\n│                                                              │\n│ $ docker ps -a                                               │\n│                                                              │\n└────[Enter]: Run  [Tab]: Insert  [f]: Follow-up  [Esc]: Cancel┘\n```\n\nYou can also follow-up with questions to get responses in natural language.\n\n```\n┌Ask questions or generate a command:──────────────────────────┐\n│                                                              │\n│ > Get a list of running docker containers                    │\n│                                                              │\n├──────────────────────────────────────────────────────────────┤\n│                                                              │\n│ $ docker ps                                                  │\n│                                                              │\n├──────────────────────────────────────────────────────────────┤\n│                                                              │\n│ > Actually I want to get all docker containers               │\n│                                                              │\n├──────────────────────────────────────────────────────────────┤\n│                                                              │\n│ $ docker ps -a                                               │\n│                                                              │\n├──────────────────────────────────────────────────────────────┤\n│                                                              │\n│ > What other useful flags to `docker ps` should I know?      │\n│                                                              │\n├──────────────────────────────────────────────────────────────┤\n│                                                              │\n│   Here are some handy `docker ps` flags:                     │\n│                                                              │\n│   - `-q` — Only show container IDs (great for piping to      │\n│   other commands)                                            │\n│   - `-s` — Show container sizes                              │\n│   - `-n 5` — Show the last 5 created containers              │\n│   - `-l` — Show only the latest created container            │\n│   - `--no-trunc` — Don't truncate output (shows full IDs and │\n│   commands)                                                  │\n│   - `-f` or `--filter` — Filter by condition, e.g.:          │\n│     - `-f status=exited` — only exited containers            │\n│     - `-f name=myapp` — filter by name                       │\n│     - `-f ancestor=nginx` — filter by image                  │\n│   - `--format` — Custom output using Go templates, e.g.:     │\n│     `--format \"table {{.Names}}\\t{{.Status}}\\t{{.Ports}}\"`   │\n│                                                              │\n│   A common combo is `docker ps -aq` to get all container     │\n│   IDs, useful for bulk operations like `docker rm $(docker   │\n│   ps -aq)`.                                                  │\n│                                                              │\n└────[Enter]: Run  [Tab]: Insert  [f]: Follow-up  [Esc]: Cancel┘\n```\n\nYou can use `enter` or `tab` at any time to run or insert the last suggested command, even if it was suggested in a previous turn.\n\n### Conversational and search usage\n\nIf you prompt the LLM with a question that doesn't imply you want to generate a command, it can respond in natural language, and use web search if necessary to fetch the data it needs.\n\n```\n┌Ask questions or generate a command:──────────────────────────┐\n│                                                              │\n│ > What is the latest version of atuin?                       │\n│                                                              │\n├──────────────────────────────────────────────────────────────┤\n│                                                              │\n│ ✓ Used 2 tools                                               │\n│                                                              │\n│   The latest version of Atuin is **v18.12.0**, available on  │\n│   the [GitHub releases                                       │\n│   page](https://github.com/atuinsh/atuin/releases).          │\n│                                                              │\n└─────────────────────────────────[f]: Follow-up  [Esc]: Cancel┘\n```\n\n### Dangerous or low-confidence command detection\n\nThe LLM scores its confidence in the command, as well as how dangerous the command is. This information is shown if a threshold is exceeded, and requires an extra confirmation step before running automatically with `enter`.\n\nThe Atuin Hub server also monitors suggested commands for dangerous patterns the LLM didn't catch, and appends its own assessment at the end of the LLM's own assessment.\n\n```\n┌Ask questions or generate a command:──────────────────────────┐\n│                                                              │\n│ > Delete all files from $HOME                                │\n│                                                              │\n├──────────────────────────────────────────────────────────────┤\n│                                                              │\n│ $ rm -rf $HOME/*                                             │\n│                                                              │\n│ ! This will PERMANENTLY delete ALL files and directories in  │\n│   your home directory, including documents, downloads,       │\n│   configurations, SSH keys, and everything else. This is     │\n│   irreversible and will likely break your system. Also note  │\n│   this won't delete hidden (dot) files — if you want those   │\n│   too, that's even more destructive.; [Server] Recursive     │\n│   delete of critical directory                               │\n│                                                              │\n└────[Enter]: Run  [Tab]: Insert  [f]: Follow-up  [Esc]: Cancel┘\n```\n"
  },
  {
    "path": "docs/docs/ai/settings.md",
    "content": "# AI Settings\n\nAll the settings that control the behavior of [Atuin AI](./introduction.md) are specified in an `[ai]` section in your `config.toml`. See [the configuration documentation](../../configuration/config/) for more detailed information about Atuin's configuration system.\n\n### enabled\n\nDefault: `false`\n\nWhether or not the AI feature are enabled. When set to `false`, the question mark keybinding will output a message with instructions to run `atuin setup` to enable the feature.\n\n### send_cwd\n\nDefault: `false`\n\nWhether or not to include your current working directory in the context sent to the LLM. By default, only your OS and current shell are sent.\n\n**Example config**\n\n```toml\n[ai]\nsend_cwd = true\n```\n\n### endpoint\n\nDefault: `null`\n\nThe address of the Atuin AI endpoint. Used for AI features like command generation. Most users will not need this setting; it is only necessary for custom AI endpoints.\n\n### api_token\n\nDefault: `null`\n\nThe API token for the Atuin AI endpoint. Used for AI features like command generation. Most users will not need this setting; it is only necessary for custom AI endpoints.\n"
  },
  {
    "path": "docs/docs/configuration/advanced-key-binding.md",
    "content": "# Advanced Atuin UI Keybinding\n\nAtuin includes a powerful keybinding system that can be used to fully customize the TUI keyboard shortcuts. Many of the configuration options, like `enter_accept`, `exit_past_line_start`, and `accept_past_line_end`, can be explicitly expressed with this new configuration.\n\nThe `[keymap]` section in your config replaces the older `[keys]` section. If any `[keymap]` settings are present, the `[keys]` section is ignored entirely.\n\n!!! warning\n    Modifier keys, F1-F24 keys, and some special characters work best - or _only_ work - with a terminal that implements the kitty keyboard protocol. Notably, the default macOS Terminal app does _not_ include this feature. For more information and a list of terminals that are known to support this protocol, see [https://sw.kovidgoyal.net/kitty/keyboard-protocol/](https://sw.kovidgoyal.net/kitty/keyboard-protocol/).\n\n## Keymaps\n\nThe Atuin TUI has multiple modes, each with its own keymap. You configure each one under a separate TOML table:\n\n| Config section       | When it is active |\n|----------------------|-------------------|\n| `[keymap.emacs]`     | Search tab, `keymap_mode = \"emacs\"` |\n| `[keymap.vim-normal]`| Search tab, `keymap_mode = \"vim\"`, normal mode |\n| `[keymap.vim-insert]`| Search tab, `keymap_mode = \"vim\"`, insert mode |\n| `[keymap.inspector]` | Inspector tab (opened with `ctrl-o`) |\n| `[keymap.prefix]`    | After pressing the prefix key (`ctrl-a` by default) |\n\nVim-insert mode inherits all emacs bindings by default, then overrides `esc` and `ctrl-[` to enter normal mode instead of exiting.\n\nYou only need to specify the keys you want to change. Unmentioned keys keep their default bindings.\n\n!!! warning\n    If you specify a key in your keymap that would normally be changed by an option, like the `enter` key with the `enter_accept` setting, the setting will not take any affect. Those options modify the default keymap based on their setting, but if you override the key in the keymap, you're responsible for managing correct behavior.\n\n## Key format\n\nKeys are specified as TOML string keys using a human-readable format.\n\n### Basic keys\n\nLowercase letters, digits, and named keys:\n\n```\n\"a\", \"z\", \"1\", \"9\"\n\"enter\", \"esc\", \"tab\", \"space\", \"backspace\", \"delete\"\n\"up\", \"down\", \"left\", \"right\"\n\"home\", \"end\", \"pageup\", \"pagedown\"\n\"f1\", \"f2\", ... \"f12\", ... \"f24\"\n```\n\n`return` is an alias for `enter`. `escape` is an alias for `esc`. `del` is an alias for `delete`.\n\n!!! warning \"macOS delete key\"\n    The key labeled \"delete\" on Mac keyboards sends `backspace` (it deletes the character *before* the cursor). The `delete` key in Atuin refers to forward-delete, which is `fn+delete` on a Mac keyboard.\n\n### Modifiers\n\nModifiers are prefixed with a dash separator. Multiple modifiers can be combined:\n\n```\n\"ctrl-c\", \"alt-f\", \"ctrl-alt-x\"\n```\n\nAvailable modifiers: `ctrl`, `alt`, `shift`, `super` (also accepted as `cmd` or `win`).\n\n!!! warning\n    The `super` modifier (Cmd on macOS, Win on Windows) **requires** the kitty keyboard protocol. Only terminals that implement this protocol will report the Super modifier to applications. Even in supported terminals, some Super+key combinations may be intercepted by the terminal or OS (e.g. Cmd+C for copy, Cmd+V for paste, or Cmd+T for opening a new tab).\n\n### Uppercase letters\n\nAn uppercase letter represents itself without needing a `shift` modifier. For example, `\"G\"` matches the `shift+g` key press.\n\n### Special characters\n\nSome special characters are written out directly:\n\n```\n\"?\", \"/\", \"[\", \"]\", \"$\"\n```\n\n### Shifted and punctuation keys\n\nWhen you press a key like `Shift+1`, your terminal sends the resulting character (`!`) rather than \"shift-1\". To bind shifted punctuation keys, use the character directly:\n\n```toml\n[keymap.emacs]\n\"!\" = \"some-action\"    # Binds to Shift+1\n\"@\" = \"some-action\"    # Binds to Shift+2\n\"#\" = \"some-action\"    # Binds to Shift+3\n\"$\" = \"cursor-end\"     # Binds to Shift+4 (vim $ motion)\n```\n\nAny single character can be used as a key binding.\n\n!!! note\n    The `shift` modifier is still valid for non-character keys like `\"shift-tab\"` or `\"shift-up\"`.\n\n### Media keys\n\nMedia keys are supported on terminals that implement the kitty keyboard protocol with `DISAMBIGUATE_ESCAPE_CODES` enabled:\n\n```\n\"play\", \"pause\", \"playpause\", \"stop\"\n\"fastforward\", \"rewind\", \"tracknext\", \"trackprevious\"\n\"record\", \"lowervolume\", \"raisevolume\", \"mutevolume\", \"mute\"\n```\n\n### Multi-key sequences\n\nSeparate keys with a space to define a sequence. The first key is buffered until the second key arrives:\n\n```\n\"g g\"\n```\n\nIf the second key does not complete a known sequence, both keys are handled individually.\n\n## Keymap format\n\nEach entry in a keymap section maps a key to either a simple action or a conditional rule list.\n\n### Simple binding\n\nMaps a key directly to a single action, with no conditions:\n\n```toml\n[keymap.emacs]\n\"ctrl-c\" = \"return-original\"\n\"enter\" = \"accept\"\n```\n\n### Conditional binding\n\nMaps a key to an ordered list of rules. Each rule has an `action` and an optional `when` condition. Rules are evaluated top-to-bottom; the first rule whose condition matches (or that has no condition) wins.\n\n```toml\n[keymap.emacs]\n\"left\" = [\n  { when = \"cursor-at-start\", action = \"exit\" },\n  { action = \"cursor-left\" },\n]\n```\n\nIn this example, pressing left when the cursor is at position 0 exits the TUI. Otherwise, it moves the cursor left.\n\nA rule without a `when` field is unconditional and always matches. It is typically placed last as a fallback.\n\n!!! warning \"Override semantics\"\n    When you specify a key in `[keymap]`, it **replaces** the **entire** default binding for that key. Other keys you don't mention keep their defaults.\n\n## Actions\n\nActions are specified as kebab-case strings.\n\n### Cursor movement\n\n| Action | Description |\n|--------|-------------|\n| `cursor-left` | Move cursor one character left |\n| `cursor-right` | Move cursor one character right |\n| `cursor-word-left` | Move cursor one word left |\n| `cursor-word-right` | Move cursor one word right |\n| `cursor-word-end` | Move cursor to end of current/next word (vim `e` motion) |\n| `cursor-start` | Move cursor to start of line |\n| `cursor-end` | Move cursor to end of line |\n\n### Editing\n\n| Action | Description |\n|--------|-------------|\n| `delete-char-before` | Delete the character before the cursor (backspace) |\n| `delete-char-after` | Delete the character after the cursor (delete) |\n| `delete-word-before` | Delete the word before the cursor |\n| `delete-word-after` | Delete the word after the cursor |\n| `delete-to-word-boundary` | Delete to the next word boundary (like `ctrl-w`) |\n| `clear-line` | Clear the entire input line |\n| `clear-to-start` | Clear the start of input line |\n| `clear-to-end` | Clear the end of input line |\n\n### List navigation\n\n| Action | Description |\n|--------|-------------|\n| `select-next` | Move selection to the next item in the results list |\n| `select-previous` | Move selection to the previous item in the results list |\n| `scroll-half-page-up` | Scroll half a page up |\n| `scroll-half-page-down` | Scroll half a page down |\n| `scroll-page-up` | Scroll a full page up |\n| `scroll-page-down` | Scroll a full page down |\n| `scroll-to-top` | Jump to the top of the list |\n| `scroll-to-bottom` | Jump to the bottom of the list |\n| `scroll-to-screen-top` | Jump to the top of the visible screen |\n| `scroll-to-screen-middle` | Jump to the middle of the visible screen |\n| `scroll-to-screen-bottom` | Jump to the bottom of the visible screen |\n\nNote: `select-next` and `select-previous` respect the `invert` setting. When `invert` is true, the visual direction is flipped.\n\n### Commands\n\n| Action | Description |\n|--------|-------------|\n| `accept` | Accept the selected entry and **execute it immediately** |\n| `accept-N` | Accept the Nth entry below the selection and execute it (e.g. `accept-1` through `accept-9`) |\n| `return-selection` | Return the selected entry to the command line **without executing** |\n| `return-selection-N` | Return the Nth entry below the selection without executing (e.g. `return-selection-1` through `return-selection-9`) |\n| `return-original` | Close the TUI and return the original command line text |\n| `return-query` | Close the TUI and return the current search query |\n| `copy` | Copy the selected entry to the clipboard |\n| `delete` | Delete the selected entry from history |\n| `exit` | Exit the TUI (behavior depends on the `exit_mode` setting) |\n| `redraw` | Redraw the screen |\n| `cycle-filter-mode` | Cycle through filter modes (global, host, session, directory) |\n| `cycle-search-mode` | Cycle through search modes (fuzzy, prefix, fulltext, skim) |\n| `toggle-tab` | Toggle between the search tab and inspector tab |\n| `switch-context` | Switch to the [context](../guide/advanced-usage.md#context-switch) of the currently selected command |\n| `clear-context` | Return to the initial [context](../guide/advanced-usage.md#context-switch) |\n\nThe difference between `accept` and `return-selection`: `accept` runs the command immediately when the TUI closes, while `return-selection` places it on your command line for further editing before you press enter. The `enter_accept` setting controls which of these the default `enter` key uses.\n\n### Mode changes\n\n| Action | Description |\n|--------|-------------|\n| `vim-enter-normal` | Switch to vim normal mode |\n| `vim-enter-insert` | Switch to vim insert mode (cursor stays in place) |\n| `vim-enter-insert-after` | Switch to vim insert mode (cursor moves right, like vim `a`) |\n| `vim-enter-insert-at-start` | Move to start of line and enter vim insert mode (like vim `I`) |\n| `vim-enter-insert-at-end` | Move to end of line and enter vim insert mode (like vim `A`) |\n| `vim-search-insert` | Clear the search input and enter vim insert mode (like vim `?` or `/`) |\n| `vim-change-to-end` | Delete to end of line and enter vim insert mode (like vim `C`) |\n| `enter-prefix-mode` | Enter prefix mode (waits for one more key, e.g. `d` for delete) |\n\n### Inspector\n\n| Action | Description |\n|--------|-------------|\n| `inspect-previous` | Inspect the previous entry (in the inspector tab) |\n| `inspect-next` | Inspect the next entry (in the inspector tab) |\n\n### Special\n\n| Action | Description |\n|--------|-------------|\n| `noop` | Do nothing (useful for disabling a default binding) |\n\n## Conditions\n\nConditions let a single key do different things depending on the current state. They are specified as strings in the `when` field of a rule.\n\n### Condition atoms\n\n| Condition | True when |\n|-----------|-----------|\n| `cursor-at-start` | The cursor is at position 0 |\n| `cursor-at-end` | The cursor is at the end of the input |\n| `input-empty` | The input line is empty (no text entered) |\n| `original-input-empty` | The original query passed to the TUI was empty |\n| `list-at-start` | The selection is at the first entry (index 0) |\n| `list-at-end` | The selection is at the last entry |\n| `no-results` | The search returned zero results |\n| `has-results` | The search returned at least one result |\n| `has-context` | The context comes from a previously selected command (`switch-context`) |\n\n### Boolean expressions\n\nConditions support boolean operators with standard precedence (`!` binds tightest, then `&&`, then `||`). Parentheses can override precedence.\n\n```toml\n# Negation\n{ when = \"!no-results\", action = \"select-next\" }\n\n# Conjunction (AND)\n{ when = \"cursor-at-start && input-empty\", action = \"exit\" }\n\n# Disjunction (OR)\n{ when = \"list-at-start || no-results\", action = \"exit\" }\n\n# Grouping with parentheses\n{ when = \"(cursor-at-start && !input-empty) || no-results\", action = \"return-original\" }\n```\n\n## Examples\n\n### Reproducing the default `[keys]` behaviors\n\nThe default keymaps already encode the standard `[keys]` behaviors. Here is what they look like as explicit `[keymap]` entries for reference.\n\n**`scroll_exits = true`** (default) -- exit when scrolling past the first entry:\n\n```toml\n[keymap.emacs]\n\"down\" = [\n  { when = \"list-at-start\", action = \"exit\" },\n  { action = \"select-next\" },\n]\n```\n\n**`exit_past_line_start = true`** (default) -- exit when pressing left at position 0:\n\n```toml\n[keymap.emacs]\n\"left\" = [\n  { when = \"cursor-at-start\", action = \"exit\" },\n  { action = \"cursor-left\" },\n]\n```\n\n**`accept_past_line_end = true`** (default) -- accept when pressing right at the end:\n\n```toml\n[keymap.emacs]\n\"right\" = [\n  { when = \"cursor-at-end\", action = \"accept\" },\n  { action = \"cursor-right\" },\n]\n```\n\n**`accept_past_line_start = true`** -- accept when pressing left at position 0 (off by default):\n\n```toml\n[keymap.emacs]\n\"left\" = [\n  { when = \"cursor-at-start\", action = \"accept\" },\n  { action = \"cursor-left\" },\n]\n```\n\n**`accept_with_backspace = true`** -- accept when pressing backspace with empty input (off by default):\n\n```toml\n[keymap.emacs]\n\"backspace\" = [\n  { when = \"cursor-at-start\", action = \"accept\" },\n  { action = \"delete-char-before\" },\n]\n```\n\n### Disabling scroll-exit\n\nTo make `down` always scroll without ever exiting:\n\n```toml\n[keymap.emacs]\n\"down\" = \"select-next\"\n```\n\n### Disabling a key entirely\n\nUse `noop` to make a key do nothing:\n\n```toml\n[keymap.emacs]\n\"ctrl-d\" = \"noop\"\n```\n\n### ctrl-d to exit only when input is empty\n\n```toml\n[keymap.emacs]\n\"ctrl-d\" = [\n  { when = \"input-empty\", action = \"exit\" },\n  { action = \"delete-char-after\" },\n]\n```\n\n### Making enter return the selection without executing\n\n```toml\n[keymap.emacs]\n\"enter\" = \"return-selection\"\n```\n\nThis is equivalent to setting `enter_accept = false`, but expressed directly as a keybinding.\n\n### Custom vim-normal bindings\n\n```toml\n[keymap.vim-normal]\n# Use 'q' to quit\n\"q\" = \"exit\"\n\n# Use 'x' to delete the selected entry\n\"x\" = \"delete\"\n\n# Use 'y' to copy\n\"y\" = \"copy\"\n```\n\n### Custom inspector bindings\n\n```toml\n[keymap.inspector]\n# Use 'delete' key in inspector to remove entries\n\"delete\" = \"delete\"\n```\n\n## Relationship with `[keys]`\n\nThe `[keymap]` section is a more powerful replacement for the `[keys]` section. The two are **mutually exclusive**:\n\n- If you have any `[keymap]` settings, the entire `[keys]` section is ignored. Defaults are built from the standard `[keys]` values, and then your `[keymap]` overrides are applied on top.\n- If you have no `[keymap]` settings, the `[keys]` section works as before for backward compatibility.\n\nIf you are migrating from `[keys]` to `[keymap]`, here is how the old flags map:\n\n| `[keys]` setting | Equivalent `[keymap]` |\n|------------------|-----------------------|\n| `scroll_exits = false` | `\"down\" = \"select-next\"` and `\"up\" = \"select-previous\"` in the relevant keymap |\n| `exit_past_line_start = false` | `\"left\" = \"cursor-left\"` |\n| `accept_past_line_end = false` | `\"right\" = \"cursor-right\"` |\n| `accept_past_line_start = true` | `\"left\" = [{ when = \"cursor-at-start\", action = \"accept\" }, { action = \"cursor-left\" }]` |\n| `accept_with_backspace = true` | `\"backspace\" = [{ when = \"cursor-at-start\", action = \"accept\" }, { action = \"delete-char-before\" }]` |\n| `prefix = \"x\"` | Prefix key becomes `ctrl-x` (set in the emacs/vim keymaps) |\n"
  },
  {
    "path": "docs/docs/configuration/config.md",
    "content": "# Config\n\nAtuin maintains two configuration files, stored in `~/.config/atuin/`. We store\ndata in `~/.local/share/atuin` (unless overridden by XDG\\_\\*).\n\nThe full path to the config file would be `~/.config/atuin/config.toml`\n\nThe config location can be overridden with ATUIN_CONFIG_DIR\n\n### `db_path`\n\nDefault: `~/.local/share/atuin/history.db`\n\nThe path to the Atuin SQLite database.\n\n```toml\ndb_path = \"~/.history.db\"\n```\n\n### `key_path`\n\nDefault: `~/.local/share/atuin/key`\n\nThe path to the Atuin encryption key.\n\n```toml\nkey_path = \"~/.atuin-key\"\n```\n\n### `session_path`\n\nDefault: `~/.local/share/atuin/session`\n\nThe path to the Atuin server session file.\nThis is essentially just an API token\n\n```toml\nsession_path = \"~/.atuin-session\"\n```\n\n### `dialect`\n\nDefault: `us`\n\nThis configures how the [stats](../reference/stats.md) command parses dates. It has two\npossible values\n\n```toml\ndialect = \"uk\"\n```\n\nor\n\n```toml\ndialect = \"us\"\n```\n\n### `auto_sync`\n\nDefault: `true`\n\nConfigures whether or not to automatically sync, when logged in.\n\n```toml\nauto_sync = true/false\n```\n\n### `update_check`\n\nDefault: `true`\n\nConfigures whether or not to automatically check for updates.\n\n```toml\nupdate_check = true/false\n```\n\n### `sync_address`\n\nDefault: `https://api.atuin.sh`\n\nThe address of the server to sync with!\n\n```toml\nsync_address = \"https://api.atuin.sh\"\n```\n\n### `sync_frequency`\n\nDefault: `1h`\n\nHow often to automatically sync with the server. This can be given in a\n\"human-readable\" format. For example, `10s`, `20m`, `1h`, etc.\n\nIf set to `0`, Atuin will sync after every command. Some servers may potentially\nrate limit, which won't cause any issues.\n\n```toml\nsync_frequency = \"1h\"\n```\n\n### `search_mode`\n\nDefault: `fuzzy`\n\nWhich search mode to use. Atuin supports \"prefix\", \"fulltext\", \"fuzzy\", \"daemon-fuzzy\", and\n\"skim\" search modes.\n\nPrefix mode searches for \"query\\*\"; fulltext mode searches for \"\\*query\\*\";\n\"fuzzy\" applies the [fuzzy search syntax](#fuzzy-search-syntax);\n\"skim\" applies the [skim search syntax](https://github.com/lotabout/skim#search-syntax).\n\n!!! note \"daemon-fuzzy search mode\"\n\n    The \"daemon-fuzzy\" mode is new as of Atuin 18.13. This search mode uses an in-memory index, stored in the daemon, to perform fast and customizable searches.\n\n    To use the new `\"daemon-fuzzy\"` mode, enable the daemon, set autostart to true (unless you manage its lifecycle yourself), and set the search mode:\n\n    ```toml\n    search_mode = \"daemon-fuzzy\"\n\n    [daemon]\n    enabled = true\n    autostart = true\n    ```\n\n    You can customize the priority given to frequency, recency, and frecency scores in this mode. See [the score multipliers section](#score-multipliers) for more information.\n\n#### `fuzzy` search syntax\n\nThe \"fuzzy\" and \"daemon-fuzzy\" search syntax is based on the\n[fzf search syntax](https://github.com/junegunn/fzf#search-syntax).\n\n| Token     | Match type                 | Description                          |\n| --------- | -------------------------- | ------------------------------------ |\n| `sbtrkt`  | fuzzy-match                | Items that match `sbtrkt`            |\n| `'wild`   | exact-match (quoted)       | Items that include `wild`            |\n| `^music`  | prefix-exact-match         | Items that start with `music`        |\n| `.mp3$`   | suffix-exact-match         | Items that end with `.mp3`           |\n| `!fire`   | inverse-exact-match        | Items that do not include `fire`     |\n| `!^music` | inverse-prefix-exact-match | Items that do not start with `music` |\n| `!.mp3$`  | inverse-suffix-exact-match | Items that do not end with `.mp3`    |\n\nA single bar character term acts as an OR operator. For example, the following\nquery matches entries that start with `core` and end with either `go`, `rb`,\nor `py`.\n\n```\n^core go$ | rb$ | py$\n```\n\n!!! warning \"Bar not supported in daemon-fuzzy\"\n    The \"daemon-fuzzy\" search mode does not currently support the bar character operator.\n\n### `filter_mode`\n\nDefault: `global`\n\nThe default filter to use when searching\n\n| Mode             | Description                                                                          |\n|------------------|--------------------------------------------------------------------------------------|\n| global (default) | Search from the full history                                                         |\n| host             | Search history from this host                                                        |\n| session          | Search history from the current session                                              |\n| directory        | Search history from the current directory                                            |\n| workspace        | Search history from the current git repository                                       |\n| session-preload  | Search from the current session and the global history from before the session start |\n\nFilter modes can still be toggled via ctrl-r\n\n```toml\nfilter_mode = \"host\"\n```\n\n### `search_mode_shell_up_key_binding`\n\nAtuin version: >= 17.0\n\nDefault: `fuzzy`\n\nThe default searchmode to use when searching and being invoked from a shell up-key binding.\n\nAccepts exactly the same options as `search_mode` above\n\n```toml\nsearch_mode_shell_up_key_binding = \"fuzzy\"\n```\n\nDefaults to the value specified for `search_mode`.\n\n### `filter_mode_shell_up_key_binding`\n\nDefault: `global`\n\nThe default filter to use when searching and being invoked from a shell up-key binding.\n\nAccepts exactly the same options as `filter_mode` above\n\n```toml\nfilter_mode_shell_up_key_binding = \"session\"\n```\n\nDefaults to the value specified for `filter_mode`.\n\n### `inline_height_shell_up_key_binding`\n\nThe maximum number of lines the interface should take up when atuin is invoked from a shell up-key binding.\n\nThe accepted values are identical to those of `inline_height`.\n\nWhen unset, the value from `inline_height` is used.\n\n### `workspaces`\n\nAtuin version: >= 17.0\n\nDefault: `false`\n\nThis flag enables a pseudo filter-mode named \"workspace\": the filter is automatically\nactivated when you are in a git repository.\n\nWith workspace filtering enabled, Atuin will filter for commands executed in any directory\nwithin a git repository tree.\n\nFilter modes can still be toggled via ctrl-r.\n\n### `style`\n\nDefault: `compact`\n\nWhich style to use. Possible values: `auto`, `full` and `compact`.\n\n- `compact`:\n\n![compact](https://user-images.githubusercontent.com/1710904/161623659-4fec047f-ea4b-471c-9581-861d2eb701a9.png)\n\n- `full`:\n\n![full](https://user-images.githubusercontent.com/1710904/161623547-42afbfa7-a3ef-4820-bacd-fcaf1e324969.png)\n\nThis means that Atuin will automatically switch to `compact` mode when the terminal window is too short for `full` to display properly.\n\n### `invert`\n\nAtuin version: >= 17.0\n\nDefault: `false`\n\nInvert the UI - put the search bar at the top.\n\n```toml\ninvert = true/false\n```\n\n### `inline_height`\n\nDefault: `40`\n\nSet the maximum number of lines Atuin's interface should take up.\n\nIf set to `0`, Atuin will always take up as many lines as available (full screen).\n\n### `show_preview`\n\nDefault: `true`\n\nConfigure whether or not to show a preview of the selected command.\n\nUseful when the command is longer than the terminal width and is cut off.\n\n### `max_preview_height`\n\nAtuin version: >= 17.0\n\nDefault: `4`\n\nConfigure the maximum height of the preview to show.\n\nUseful when you have long scripts in your history that you want to distinguish by more than the first few lines.\n\n### `show_help`\n\nAtuin version: >= 17.0\n\nDefault: `true`\n\nConfigure whether or not to show the help row, which includes the current Atuin version (and whether an update is available), a keymap hint, and the total amount of commands in your history.\n\n### `show_tabs`\n\nAtuin version: >= 18.0\n\nDefault: `true`\n\nConfigure whether or not to show tabs for search and inspect.\n\n### `auto_hide_height`\n\nAtuin version: >= 18.4\n\nDefault: `8`\n\nSet Atuin to hide lines when a minimum number of rows is subceeded. This has no effect except\nwhen `compact` style is being used (see `style` above), and currently applies to only the\ninteractive search and inspector. It can be turned off entirely by setting to `0`.\n\n### `exit_mode`\n\nDefault: `return-original`\n\nWhat to do when the escape key is pressed when searching\n\n| Value                     | Behaviour                                                        |\n| ------------------------- | ---------------------------------------------------------------- |\n| return-original (default) | Set the command-line to the value it had before starting search  |\n| return-query              | Set the command-line to the search query you have entered so far |\n\nPressing ctrl+c or ctrl+d will always return the original command-line value.\n\n```toml\nexit_mode = \"return-query\"\n```\n\n### `history_format`\n\nDefault to `history list`\n\nThe history format allows you to configure the default `history list` format - which can also be specified with the --format arg.\n\nThe specified --format arg will prioritize the config when both are present\n\nMore on [history list](../reference/list.md)\n\n### `history_filter`\n\nThe history filter allows you to exclude commands from history tracking - maybe you want to keep ALL of your `curl` commands totally out of your shell history, or maybe just some matching a pattern.\n\nThis supports regular expressions, so you can hide pretty much whatever you want!\n\n```toml\n## Note that these regular expressions are unanchored, i.e. if they don't start\n## with ^ or end with $, they'll match anywhere in the command.\nhistory_filter = [\n   \"^secret-cmd\",\n   \"^innocuous-cmd .*--secret=.+\"\n]\n```\n\n### `cwd_filter`\n\nThe cwd filter allows you to exclude directories from history tracking.\n\nThis supports regular expressions, so you can hide pretty much whatever you want!\n\n```toml\n## Note that these regular expressions are unanchored, i.e. if they don't start\n## with ^ or end with $, they'll match anywhere in the command.\n# cwd_filter = [\n#   \"^/very/secret/directory\",\n# ]\n```\n\nAfter updating that parameter, you can run [the prune command](../reference/prune.md) to remove old history entries that match the new filters.\n\n### `store_failed`\n\nAtuin version: >= 18.3.0\n\nDefault: `true`\n\n```toml\nstore_failed = true\n```\n\nConfigures whether to store commands that failed (those with non-zero exit status) or not.\n\n### `secrets_filter`\n\nAtuin version: >= 17.0\n\nDefault: `true`\n\n```toml\nsecrets_filter = true\n```\n\nThis matches history against a set of default regex, and will not save it if we get a match. Defaults include\n\n1. AWS key id\n2. Github pat (old and new)\n3. Slack oauth tokens (bot, user)\n4. Slack webhooks\n5. Stripe live/test keys\n6. Atuin login command\n7. Cloud environment variable patterns (`AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AZURE_STORAGE_CLASS_KEY`, `GOOGLE_SERVICE_ACCOUNT_KEY`)\n8. Netlify authentication tokens\n9. Npm pat\n10. Pulumi pat\n\n### macOS Ctrl-n key shortcuts\n\nDefault: `true`\n\nmacOS does not have an ++alt++ key, although terminal emulators can often be configured to map the ++option++ key to be used as ++alt++. _However_, remapping ++option++ this way may prevent typing some characters, such as using ++option+3++ to type `#` on the British English layout. For such a scenario, set the `ctrl_n_shortcuts` option to `true` in your config file to replace ++alt+0++ to ++alt+9++ shortcuts with ++ctrl+0++ to ++ctrl+9++ instead:\n\n```toml\n# Use Ctrl-0 .. Ctrl-9 instead of Alt-0 .. Alt-9 UI shortcuts\nctrl_n_shortcuts = true\n```\n\n### `network_timeout`\n\nAtuin version: >= 18.0\n\nDefault: `30`\n\nThe max amount of time (in seconds) to wait for a network request. If any\noperations with a sync server take longer than this, the code will fail -\nrather than wait indefinitely.\n\n### `network_connect_timeout`\n\nAtuin version: >= 18.0\n\nDefault: `5`\n\nThe max time (in seconds) we wait for a connection to become established with a\nremote sync server. Any longer than this and the request will fail.\n\n### `local_timeout`\n\nAtuin version: >= 18.0\n\nDefault: `5`\n\nTimeout (in seconds) for acquiring a local database connection (sqlite).\n\n### `command_chaining`\n\nAtuin version: >= 18.8\n\nDefault: `false`\n\nAllows building a command chain with the `&&` or `||` operator. When enabled, opening atuin will search for the next command in the chain, and append to the current buffer.\n\n### `enter_accept`\n\nAtuin version: >= 17.0\n\nDefault: `false`\n\nWhen set to true, Atuin will default to immediately executing a command rather\nthan the user having to press enter twice. Pressing tab will return to the\nshell and give the user a chance to edit.\n\nThis technically defaults to true for new users, but false for existing. We\nhave set `enter_accept = true` in the default config file. This is likely to\nchange to be the default for everyone in a later release.\n\n### `keymap_mode`\n\nAtuin version: >= 18.0\n\nDefault: `emacs`\n\nThe initial keymap mode of the interactive Atuin search (e.g. started by the\nkeybindings in the shells). There are four supported values: `\"emacs\"`,\n`\"vim-normal\"`, `\"vim-insert\"`, and `\"auto\"`. The keymap mode `\"emacs\"` is the\nmost basic one. In the keymap mode `\"vim-normal\"`, you may use ++k++\nand ++j++ to navigate the history list as in Vim, whilst pressing\n\n++i++ changes the keymap mode to `\"vim-insert\"`. In the keymap mode `\"vim-insert\"`,\nyou can search for a string as in the keymap mode `\"emacs\"`, while pressing ++esc++\nswitches the keymap mode to `\"vim-normal\"`. When set to `\"auto\"`, the initial\nkeymap mode is automatically determined based on the shell's keymap that triggered\nthe Atuin search. `\"auto\"` is not supported by NuShell at present, where it will\nalways trigger the Atuin search with the keymap mode `\"emacs\"`.\n\n### `keymap_cursor`\n\nAtuin version: >= 18.0\n\nDefault: `(empty dictionary)`\n\nThe terminal's cursor style associated with each keymap mode in the Atuin\nsearch. This is specified by a dictionary whose keys and values being the\nkeymap names and the cursor styles, respectively. A key specifies one of the\nkeymaps from `emacs`, `vim_insert`, and `vim_normal`. A value is one of the\ncursor styles, `default` or `{blink,steady}-{block,underline,bar}`. The\nfollowing is an example.\n\n```toml\nkeymap_cursor = { emacs = \"blink-block\", vim_insert = \"blink-block\", vim_normal = \"steady-block\" }\n```\n\nIf the cursor style is specified, the terminal's cursor style is changed to the\nspecified one when the Atuin search starts with or switches to the\ncorresponding keymap mode. Also, the terminal's cursor style is reset to the\none associated with the keymap mode corresponding to the shell's keymap on the\ntermination of the Atuin search.\n\n### `prefers_reduced_motion`\n\nAtuin version: >= 18.0\n\nDefault: `false`\n\nEnable this, and Atuin will reduce motion in the TUI as much as possible. Users\nwith motion sensitivity can find the live-updating timestamps distracting.\n\nAlternatively, set env var NO_MOTION\n\n## search\n\n### `filters`\n\nAtuin version: >= 18.4\n\nThe list of filter modes available in interactive search, in the order they cycle through when you press ctrl-r. By default, all modes are enabled. Removing a mode from this list disables it entirely. The `workspace` mode is skipped when not in a git repository or when `workspaces = false`. See [`filter_mode`](#filter_mode) for a description of each mode.\n\nThe `filter_mode` setting selects the initial mode from this list. If `filter_mode` is set to a mode not in the list, the first available mode is used instead.\n\n```toml\n[search]\nfilters = [\"global\", \"host\", \"session\", \"directory\"]\n```\n\n### Score multipliers\n\nFor the [`\"daemon-fuzzy\"` search mode](#search_mode), you can control the scoring of matched items. The system scores matches based on three numbers: frequency, recency, and frecency:\n\n* Frequency — how often this exact match has been run, with diminishing returns\n* Recency — how recently this exact match was last run\n* Frecency — a combination of frequency and recency\n\nThe frecency calculation is `Recency Score * Recency Multiplier + Frequency Score * Frequency Multiplier`. By changing the options below, you can customize the relative importance of each part of the score calculation.\n\nFor each setting, a value of `1.0` (the default) means the score is used as-is. Values less than `1.0` decrease that score's influence, and values greater than `1.0` increase that score's influence.\n\nSo, for example, if you cared a lot about how frequently you run a command but not as much how recently, you could set `frequency_score_multiplier` to `10.0` and `recency_score_multiplier` to `0.1`.\n\n!!! warning \"daemon-fuzzy mode only\"\n    The score multiplier settings shown here only work with the `\"daemon-fuzzy\"` search mode.\n\n#### `frequency_score_multiplier`\n\nDefault: `1.0`\n\nThe multiplier to apply to the frequency score in the frecency calculation. Setting this to `0` disables the frequency portion of the frecency scoring altogether.\n\n#### `recency_score_multiplier`\n\nDefault: `1.0`\n\nThe multiplier to apply to the recency score in the frecency calculation. Setting this to `0` disables the recency portion of the frecency scoring altogether.\n\n#### `frecency_score_multiplier`\n\nDefault: `1.0`\n\nThe multiplier used for the final frecency score. Setting this to `0` disables frecency scoring altogether, relying solely on the fuzzy matcher's score.\n\nExample:\n\n```toml\nsearch_mode = \"daemon-fuzzy\"\n\n[daemon]\nenabled = true\nautostart = true\n\n[search]\nrecency_score_multiplier = 10.0\nfrequency_score_multiplier = 0.8\nfrecency_score_multiplier = 2.0\n```\n\n## Stats\n\nThis section of client config is specifically for configuring Atuin stats calculations\n\n```\n[stats]\ncommon_subcommands = [...]\ncommon_prefix = [...]\n```\n\n### `common_subcommands`\n\nDefault:\n\n```toml\ncommon_subcommands = [\n  \"apt\",\n  \"cargo\",\n  \"composer\",\n  \"dnf\",\n  \"docker\",\n  \"git\",\n  \"go\",\n  \"ip\",\n  \"jj\",\n  \"kubectl\",\n  \"nix\",\n  \"nmcli\",\n  \"npm\",\n  \"pecl\",\n  \"pnpm\",\n  \"podman\",\n  \"port\",\n  \"systemctl\",\n  \"tmux\",\n  \"yarn\",\n]\n```\n\nConfigures commands where we should consider the subcommand as part of the statistics. For example, consider `kubectl get` rather than just `kubectl`.\n\n### `common_prefix`\n\nAtuin version: >= 17.1\n\nDefault:\n\n```toml\ncommon_prefix = [\n  \"sudo\",\n]\n```\n\nConfigures commands that should be totally stripped from stats calculations. For example, 'sudo' should be ignored.\n\n## sync\n\nWe have developed a new version of sync, that is both faster and more efficient than the original version.\n\nPresently, it is the default for fresh installs but not for existing users. This will change in a later release.\n\nTo enable sync v2, add the following to your config\n\n```toml\n[sync]\nrecords = true\n```\n\n## `dotfiles`\n\nAtuin version: >= 18.1\n\nDefault: `false`\n\nTo enable sync of shell aliases between hosts. Requires `sync` enabled.\n\nAdd the new section to the bottom of your config file, for every machine you use Atuin with\n\n```toml\n[dotfiles]\nenabled = true\n```\n\nNote: you will need to have sync v2 enabled. See the above section.\n\nManage aliases using the command line options\n\n```\n# Alias 'k' to 'kubectl'\natuin dotfiles alias set k kubectl\n\n# List all aliases\natuin dotfiles alias list\n\n# Delete an alias\natuin dotfiles alias delete k\n```\n\nAfter setting an alias, you will either need to restart your shell or source the init file for the change to take affect\n\n## keys\n\nThis section of the client config is specifically for configuring key-related settings.\n\n```\n[keys]\nscroll_exits = [...]\nprefix = 'a'\n```\n\n### `scroll_exits`\n\nAtuin version: >= 18.1\n\nDefault: `true`\n\nConfigures whether the TUI exits, when scrolled past the last or first entry.\n\n### `prefix`\n\nAtuin version: > 18.3\n\nDefault: `a`\n\nWhich key to use as the prefix\n\n### `exit_past_line_start`\n\nAtuin version: >= 18.5\n\nDefault: `true`\n\nExits the TUI when scrolling left while the cursor is at the start of the line.\n\n### `accept_past_line_end`\n\nAtuin version: >= 18.5\n\nDefault: `true`\n\nThe right arrow key performs the same functionality as Tab and copies the selected line to the command line to be\nmodified.\n\n### `accept_past_line_start`\n\nAtuin version: >= 18.9\n\nDefault: `false`\n\nThe left arrow key performs the same functionality as Tab and copies the selected line to the command line to be\nmodified.\n\n### `accept_with_backspace`\n\nAtuin version: >= 18.9\n\nDefault: `false`\n\nThe backspace key performs the same functionality as Tab and copies the selected line to the command line to be\nmodified.\n\n## preview\n\nThis section of the client config is specifically for configuring preview-related settings.\n(In the future the other 2 preview settings will be moved here.)\n\n```\n[preview]\nstrategy = [...]\n```\n\n### `strategy`\n\nAtuin version: >= 18.3\n\nDefault: `auto`\n\nWhich preview strategy is used to calculate the preview height. It respects `max_preview_height`.\n\n| Value          | Preview height is calculated from the length of the |\n| -------------- | --------------------------------------------------- |\n| auto (default) | selected command                                    |\n| static         | longest command in the current result set           |\n| fixed          | use `max_preview_height` as fixed value             |\n\nBy using `auto` a preview is shown, if the command is longer than the width of the terminal.\n\n## Daemon\n\nAtuin version: >= 18.3\n\n### enabled\n\nDefault: `false`\n\nEnable the background daemon\n\nAdd the new section to the bottom of your config file\n\n```toml\n[daemon]\nenabled = true\n```\n\n### autostart\n\nDefault: `false`\n\nAutomatically start and manage the daemon when needed.\nThis is not compatible with `systemd_socket = true`.\nIf a legacy experimental daemon is already running, restart it manually once before using autostart.\n\n```toml\nautostart = false\n```\n\n### sync_frequency\n\nDefault: `300`\n\nHow often the daemon should sync, in seconds\n\n```toml\nsync_frequency = 300\n```\n\n### socket_path\n\nDefault:\n\n```toml\nsocket_path = \"~/.local/share/atuin/atuin.sock\"\n```\n\nWhere to bind a unix socket for client -> daemon communication\n\nIf XDG_RUNTIME_DIR is available, then we use this directory instead.\n\n### pidfile_path\n\nDefault:\n\n```toml\npidfile_path = \"~/.local/share/atuin/atuin-daemon.pid\"\n```\n\nPath to the daemon pidfile used for process coordination.\n\n### systemd_socket\n\nDefault `false`\n\nUse a socket passed via systemd socket activation protocol instead of the path\n\n```toml\nsystemd_socket = false\n```\n\n### tcp_port\n\nDefault: `8889`\n\nThe port to use for client -> daemon communication. Only used on non-unix systems.\n\n```toml\ntcp_port = 8889\n```\n\n## logs\n\nAtuin version: >= 18.13\n\nBehavior of log files.\n\n### enabled\n\nDefault: `true`\n\nWhether or not to enable file-based logging.\n\n### dir\n\nDefault: `\"~/.atuin/logs\"`\n\nThe directory in which to store log files.\n\n### level\n\nDefault: `\"info\"`\n\nThe logging level to use. Valid values are `\"trace\"`, `\"debug\"`, `\"info\"`, `\"warn\"`, and `\"error\"`, in order of highest-to-lowest verbosity.\n\n### retention\n\nDefault: `4`\n\nHow many days of log files to keep (per file type). Files older than this will be removed.\n\n### ai\n\nA sub-object with specific options for AI logging:\n\n* `enabled` - whether to output AI logs; defaults to `logs.enabled`\n* `file` - the filename to use for the AI logs; defaults to `\"ai.log\"`. Always relative to `logs.dir`.\n* `level` - override the log level for the AI logs; defaults to `logs.level`\n* `retention` - how many days to store AI logs; defaults to `logs.retention`\n\n### daemon\n\nA sub-object with specific options for daemon logging:\n\n* `enabled` - whether to output daemon logs; defaults to `logs.enabled`\n* `file` - the filename to use for the daemon logs; defaults to `\"daemon.log\"`. Always relative to `logs.dir`.\n* `level` - override the log level for the daemon logs; defaults to `logs.level`\n* `retention` - how many days to store daemon logs; defaults to `logs.retention`\n\n### search\n\nA sub-object with specific options for search logging:\n\n* `enabled` - whether to output search logs; defaults to `logs.enabled`\n* `file` - the filename to use for the search logs; defaults to `\"search.log\"`. Always relative to `logs.dir`.\n* `level` - override the log level for the search logs; defaults to `logs.level`\n* `retention` - how many days to store search logs; defaults to `logs.retention`\n\n## theme\n\nAtuin version: >= 18.4\n\nThe theme to use for showing the terminal interface.\n\n```toml\n[theme]\nname = \"default\"\ndebug = false\nmax_depth = 10\n```\n\n### `name`\n\nDefault: `\"default\"`\n\nA theme name that must be present as a built-in (unset or `default` for the default,\nelse `autumn` or `marine`), or found in the themes directory, with the suffix `.toml`.\nBy default this is `~/.config/atuin/themes/` but can be overridden with the\n`ATUIN_THEME_DIR` environment variable.\n\n```toml\nname = \"my-theme\"\n```\n\n### `debug`\n\nDefault: `false`\n\nOutput information about why a theme will not load. Independent from other log\nlevels as it can cause data from the theme file to be printed unfiltered to the\nterminal.\n\n```toml\ndebug = false\n```\n\n### `max_depth`\n\nDefault: 10\n\nNumber of levels of \"parenthood\" that will be traversed for a theme. This should not\nneed to be added in or changed in normal usage.\n\n```toml\nmax_depth = 10\n```\n\n## ui\n\nAtuin version: >= 18.5\n\nConfigure the interactive search UI appearance.\n\n```toml\n[ui]\ncolumns = [\"duration\", \"time\", \"command\"]\n```\n\n### `columns`\n\nDefault: `[\"duration\", \"time\", \"command\"]`\n\nColumns to display in the interactive search, from left to right. The selection\nindicator (`\" > \"`) is always shown first implicitly.\n\nEach column can be specified as:\n- A simple string (uses default width): `\"duration\"`\n- An object with type and optional width/expand: `{ type = \"directory\", width = 30 }`\n\n#### Available column types\n\n| Column    | Default Width | Description                                     |\n| --------- | ------------- | ----------------------------------------------- |\n| duration  | 5             | Command execution duration (e.g., \"123ms\")      |\n| time      | 8             | Relative time since execution (e.g., \"59m ago\") |\n| datetime  | 16            | Absolute timestamp (e.g., \"2025-01-22 14:35\")   |\n| directory | 20            | Working directory (truncated if too long)       |\n| host      | 15            | Hostname where command was run                  |\n| user      | 10            | Username                                        |\n| exit      | 3             | Exit code (colored by success/failure)          |\n| command   | *             | The command itself (expands by default)         |\n\n#### Column options\n\n- **type**: The column type (required when using object format)\n- **width**: Custom width in characters (optional, uses default if not specified)\n- **expand**: If `true`, the column fills remaining space. Default is `true` for `command`, `false` for others. Only one column should have `expand = true`.\n\n#### Examples\n\n```toml\n# Minimal - more space for commands\ncolumns = [\"duration\", \"command\"]\n\n# With custom directory width\ncolumns = [\"duration\", { type = \"directory\", width = 30 }, \"command\"]\n\n# Show host for multi-machine sync users\ncolumns = [\"duration\", \"time\", \"host\", \"command\"]\n\n# Show exit codes prominently\ncolumns = [\"exit\", \"duration\", \"command\"]\n\n# Make directory expand instead of command\ncolumns = [\"duration\", \"time\", { type = \"directory\", expand = true }, { type = \"command\", expand = false }]\n```\n\n## ai\n\nThe settings for Atuin AI are listed in [a separate section](../../ai/settings/).\n"
  },
  {
    "path": "docs/docs/configuration/key-binding.md",
    "content": "# Key Binding\n\n## Custom up arrow filter mode\n\nIt can be useful to use a different filter or search mode on the up arrow. For example, you could use ctrl-r for searching globally, but the up arrow for searching history from the current directory only.\n\nSet your config like this:\n\n```\nfilter_mode_shell_up_key_binding = \"directory\" # or global, host, directory, etc\n```\n\n## Disable up arrow\n\nOur default up-arrow binding can be a bit contentious. Some people love it, some people hate it. Many people who found it a bit jarring at first have since come to love it, so give it a try!\n\nIt becomes much more powerful if you consider binding a different filter mode to the up arrow. For example, on \"up\" Atuin can default to searching all history for the current directory only, while ctrl-r searches history globally. See the [config](config.md#filter_mode_shell_up_key_binding) for more.\n\nOtherwise, if you don't like it, it's easy to disable.\n\nYou can also disable either the up-arrow or ++ctrl+r++ bindings individually, by passing\n`--disable-up-arrow` or `--disable-ctrl-r` to the call to `atuin init` in your shell config file:\n\nAn example for zsh:\n```\n# Bind ctrl-r but not up arrow\neval \"$(atuin init zsh --disable-up-arrow)\"\n\n# Bind up-arrow but not ctrl-r\neval \"$(atuin init zsh --disable-ctrl-r)\"\n```\n\nIf you do not want either key to be bound, either pass both `--disable` arguments, or set the\nenvironment variable `ATUIN_NOBIND` to any value before the call to `atuin init`:\n\n```\n## Do not bind any keys\n# Either:\neval \"$(atuin init zsh --disable-up-arrow --disable-ctrl-r)\"\n\n# Or:\nexport ATUIN_NOBIND=\"true\"\neval \"$(atuin init zsh)\"\n```\n\nYou can then choose to bind Atuin if needed, do this after the call to init.\n\n## Enter key behavior\n\nBy default, the `enter` key will directly execute the selected command instead of letting you edit it like the `tab` key. If you want to change this behavior, set `enter_accept = false` in your config. For more details: [enter_accept](config.md#enter_accept).\n\n## Ctrl-n key shortcuts\n\nmacOS does not have an ++alt++ key, although terminal emulators can often be configured to map the ++option++ key to be used as ++alt++. *However*, remapping ++option++ this way may prevent typing some characters, such as using ++option+3++ to type `#` on the British English layout. For such a scenario, set the `ctrl_n_shortcuts` option to `true` in your config file to replace ++alt+0++ to ++alt+9++ shortcuts with ++ctrl+0++ to ++ctrl+9++ instead:\n\n```\n# Use Ctrl-0 .. Ctrl-9 instead of Alt-0 .. Alt-9 UI shortcuts\nctrl_n_shortcuts = true\n```\n\nGhostty on Linux maps ++alt+1++ .. ++alt+9++ for switching between tabs by number. To disable this behavior either add the following to ~/.config/ghostty/config:\n```\nkeybind=alt+one=unbind\nkeybind=alt+two=unbind\nkeybind=alt+three=unbind\nkeybind=alt+four=unbind\nkeybind=alt+five=unbind\nkeybind=alt+six=unbind\nkeybind=alt+seven=unbind\nkeybind=alt+eight=unbind\nkeybind=alt+nine=unbind\n```\n(this will disable tab switching by ++alt+n++)\nor use the `ctrl_n_shortcuts` as outlined above.\n\n## zsh\n\nIf you'd like to customize your bindings further, it's possible to do so with custom shell config:\n\nAtuin defines the ZLE widgets \"atuin-search\" and \"atuin-up-search\".  The latter\ncan be used for the keybindings to the ++up++ key and similar keys.\n\nNote: instead use the widget names \"\\_atuin\\_search\\_widget\" and \"\\_atuin\\_up\\_search\\_widget\", respectively, in `atuin < 18.0`\n\n```\nexport ATUIN_NOBIND=\"true\"\neval \"$(atuin init zsh)\"\n\nbindkey '^r' atuin-search\n\n# bind to the up key, which depends on terminal mode\nbindkey '^[[A' atuin-up-search\nbindkey '^[OA' atuin-up-search\n```\n\nFor the keybindings in vi mode, \"atuin-search-viins\", \"atuin-search-vicmd\",\n\"atuin-up-search-viins\", and \"atuin-up-search-vicmd\" (`atuin >= 18.0`) can be\nused in combination with the config\n[\"keymap\\_mode\"](config.md#keymap_mode)\n(`atuin >= 18.0`) to start the Atuin search in respective keymap modes.\n\n## bash\n\nAtuin (`>= 18.10.0`) provides a shell function `atuin-bind` to set up\nkeybindings easily:\n\n```\natuin-bind [-m KEYMAP] KEYSEQ COMMAND\n```\n\n`KEYMAP` is one of `emacs`, `vi-insert`, and `vi-command` and specifies the\ntarget keymap where the keybinding is defined.  `KEYSEQ` specifies a key\nsequence in the format used in `bind '\"KEYSEQ\": ...'`.  `COMMAND` specifies a\nshell command to run with the keybindings.  The following special commands can\nbe used as well as an arbitrary shell command:\n\n| Command                 | Description                                                                         |\n| ----------------------- | ----------------------------------------------------------------------------------- |\n| `atuin-search`          | Standard search                                                                     |\n| `atuin-search-emacs`    | Standard search with the `emacs` keymap mode                                        |\n| `atuin-search-viins`    | Standard search with the `vim-insert` keymap mode                                   |\n| `atuin-search-vicmd`    | Standard search with the `vim-normal` keymap mode                                   |\n| `atuin-up-search`       | Search command for <kbd>up</kbd> or similar keys                                    |\n| `atuin-up-search-emacs` | Search command for <kbd>up</kbd> or similar keys, with the `emacs` keymap mode      |\n| `atuin-up-search-viins` | Search command for <kbd>up</kbd> or similar keys, with the `vim-insert` keymap mode |\n| `atuin-up-search-vicmd` | Search command for <kbd>up</kbd> or similar keys, with the `vim-nomarl` keymap mode |\n\nThe keymap mode controls the initial keymap in the Atuin search and is\ndetermined in combination with the config\n[\"keymap\\_mode\"](config.md#keymap_mode)\n(`atuin >= 18.0`).\n\n\n```\nexport ATUIN_NOBIND=\"true\"\neval \"$(atuin init bash)\"\n\n# bind to ctrl-r, add any other bindings you want here too\natuin-bind '\\C-r' atuin-search\n# example of CTRL-upkey\n# atuin-bind '\\e[1;5A' atuin-search\n\n# bind to the up key, which depends on terminal mode\natuin-bind '\\e[A' atuin-up-search\natuin-bind '\\eOA' atuin-up-search\n```\n\nWith older versions of Atuin, the user needs to bind a bindable shell function\n\"`__atuin_history`\" directly using Bash's `bind`.  The flag\n`--shell-up-key-binding` can be optionally specified to the first argument for\nkeybindings to the <kbd>up</kbd> key or similar keys.  For the keybindings in\nthe `vi` editing mode, the options `--keymap-mode=vim-insert` and the keymap\nmode `--keymap-mode=vim-normal` (`atuin >= 18.0`) can be additionally specified\nto the shell function `__atuin_history`.\n\n## fish\nEdit key bindings in FISH shell by adding the following to ~/.config/fish/config.fish\n\n```\nset -gx ATUIN_NOBIND \"true\"\natuin init fish | source\n\n# bind to ctrl-r in normal and insert mode, add any other bindings you want here too\nbind \\cr _atuin_search\nbind -M insert \\cr _atuin_search\n```\n\nFor the ++up++ keybinding, `_atuin_bind_up` can be used instead of `_atuin_search`.\n\nAdding the useful alternative key binding of ++ctrl+up++ is tricky and determined by the terminals adherence to terminfo(5).\n\nConveniently FISH uses a command to capture keystrokes and advises you of the exact command to add for your specific terminal.\nIn your terminal, run `fish_key_reader` then punch the desired keystroke/s.\n\nFor example, in Gnome Terminal the output to ++ctrl+up++ is `bind \\e\\[1\\;5A 'do something'`\n\nSo, adding this to the above sample, `bind \\e\\[1\\;5A _atuin_search` will provide the additional search keybinding.\n\n## nu\n\n```\n$env.ATUIN_NOBIND = true\natuin init nu | save -f ~/.local/share/atuin/init.nu #make sure you created the directory beforehand with `mkdir ~/.local/share/atuin/init.nu`\nsource ~/.local/share/atuin/init.nu\n\n#bind to ctrl-r in emacs, vi_normal and vi_insert modes, add any other bindings you want here too\n$env.config = (\n    $env.config | upsert keybindings (\n        $env.config.keybindings\n        | append {\n            name: atuin\n            modifier: control\n            keycode: char_r\n            mode: [emacs, vi_normal, vi_insert]\n            event: { send: executehostcommand cmd: (_atuin_search_cmd) }\n        }\n    )\n)\n```\n\n\n## Atuin UI shortcuts\n\n| Shortcut                                  | Action                                                                        |\n|-------------------------------------------|-------------------------------------------------------------------------------|\n| enter                                     | Execute selected item                                                         |\n| tab                                       | Select item and edit                                                          |\n| ctrl + r                                  | Cycle through filter modes                                                    |\n| ctrl + s                                  | Cycle through search modes                                                    |\n| alt + 1 to alt + 9                        | Select item by the number located near it                                     |\n| ctrl + c / ctrl + d / ctrl + g / esc      | Return original                                                               |\n| ctrl + y                                  | Copy selected item to clipboard                                               |\n| ctrl + ← / alt + b                        | Move the cursor to the previous word                                          |\n| ctrl + → / alt + f                        | Move the cursor to the next word                                              |\n| ctrl + b / ←                              | Move the cursor to the left                                                   |\n| ctrl + f / →                              | Move the cursor to the right                                                  |\n| ctrl + a / home                           | Move the cursor to the start of the line                                      |\n| ctrl + e / end                            | Move the cursor to the end of the line                                        |\n| ctrl + backspace / ctrl + alt + backspace | Remove the previous word / remove the word just before the cursor             |\n| ctrl + delete / ctrl + alt + delete       | Remove the next word or the word just after the cursor                        |\n| ctrl + w                                  | Remove the word before the cursor even if it spans across the word boundaries |\n| ctrl + u                                  | Clear the current line                                                        |\n| ctrl + n / ctrl + j / ↑                   | Select the next item on the list                                              |\n| ctrl + p / ctrl + k / ↓                   | Select the previous item on the list                                          |\n| ctrl + o                                  | Open the [inspector](#inspector)                                              |\n| page down                                 | Scroll search results one page down                                           |\n| page up                                   | Scroll search results one page up                                             |\n| ↓ (with no entry selected)                | Return original or return query depending on [settings](config.md#exit_mode)  |\n| ↓                                         | Select the next item on the list                                              |\n| ctrl + a, c                               | Switch to the context of the currently selected command / return to default   |\n\n\n### Vim mode\nIf [vim is enabled in the config](config.md#keymap_mode), the following keybindings are enabled:\n\n| Shortcut | Mode   | Action                                     |\n| -------- | ------ | ------------------------------------------ |\n| k        | Normal | Selects the next item on the list          |\n| j        | Normal | Selects the previous item on the list      |\n| h        | Normal | Move cursor left                           |\n| l        | Normal | Move cursor right                          |\n| 0        | Normal | Move cursor to start of line               |\n| $        | Normal | Move cursor to end of line                 |\n| w        | Normal | Move cursor to next word                   |\n| b        | Normal | Move cursor to previous word               |\n| e        | Normal | Move cursor to end of current/next word    |\n| x        | Normal | Delete character under cursor              |\n| dd       | Normal | Clear the entire line                      |\n| D        | Normal | Delete to end of line                      |\n| C        | Normal | Delete to end of line and enter insert     |\n| i        | Normal | Enters insert mode                         |\n| I        | Normal | Move to start of line and enter insert     |\n| a        | Normal | Move right and enter insert mode           |\n| A        | Normal | Move to end of line and enter insert       |\n| Ctrl+u   | Normal | Half-page up (toward visual top)           |\n| Ctrl+d   | Normal | Half-page down (toward visual bottom)      |\n| Ctrl+b   | Normal | Full-page up (toward visual top)           |\n| Ctrl+f   | Normal | Full-page down (toward visual bottom)      |\n| G        | Normal | Jump to visual bottom of history           |\n| gg       | Normal | Jump to visual top of history              |\n| H        | Normal | Jump to top of visible screen              |\n| M        | Normal | Jump to middle of visible screen           |\n| L        | Normal | Jump to bottom of visible screen           |\n| ? or /   | Normal | Clear input and enter insert mode          |\n| 1-9      | Normal | Select item by number                      |\n| enter    | Normal | Execute selected item (respects enter_accept) |\n| Esc      | Insert | Enters normal mode                         |\n\n\n### Inspector\nOpen the inspector with ctrl + o\n\n| Shortcut  | Action                                        |\n| --------- | --------------------------------------------- |\n| Esc       | Close the inspector, returning to the shell   |\n| ctrl + o  | Close the inspector, returning to search view |\n| ctrl + d  | Delete the inspected item from the history    |\n| ↑         | Inspect the previous item in the history      |\n| ↓         | Inspect the next item in the history          |\n| page up   | Inspect the previous item in the history      |\n| page down | Inspect the next item in the history          |\n| j / k     | Navigate items (when vim mode is enabled)     |\n| enter     | Execute selected item (respects enter_accept) |\n| tab       | Select current item and edit                  |\n"
  },
  {
    "path": "docs/docs/faq.md",
    "content": "# FAQ\n\n## Why isn't Atuin recording commands in my IDE's terminal?\n\nIDEs like PyCharm, VS Code, and others often start non-interactive shells that don't source your shell configuration. This means Atuin's hooks never get installed.\n\nTo fix this, configure your IDE to start an interactive shell (e.g., `/bin/bash -i` instead of `/bin/bash`).\n\nSee [Shell Integration and Interoperability](guide/shell-integration.md) for detailed instructions.\n\n## How do I exclude certain commands from my history?\n\nUse the `history_filter` option in `~/.config/atuin/config.toml`:\n\n```toml\nhistory_filter = [\n    \"^secret-cmd\",\n    \"^ls$\",\n]\n```\n\nYou can also exclude commands by directory with `cwd_filter`, or prefix individual commands with a space.\n\nSee [Shell Integration and Interoperability](guide/shell-integration.md#excluding-commands-from-history) for more options.\n\n## How do I remove the default up arrow binding?\n\nOpen your shell config file, find the line containing `atuin init`.\n\nAdd `--disable-up-arrow`\n\nEG:\n\n```\neval \"$(atuin init zsh --disable-up-arrow)\"\n```\n\nSee [key binding](../configuration/key-binding.md) for more\n\n## How do I remove the default question mark binding for Atuin AI?\n\nOpen your shell config file, find the line containing `atuin init`.\n\nAdd `--disable-ai`\n\nEG:\n\n```\neval \"$(atuin init zsh --disable-ai)\"\n```\n\n## How do I edit a command instead of running it immediately?\n\nPress tab! By default, enter will execute a command, and tab will insert it ready for editing.\n\nYou can make `enter` edit a command by putting `enter_accept = false` into your config file (~/.config/atuin/config.toml)\n\n## How do I delete my account?\n\n**Attention:** This command does not prompt for confirmation.\n\n```\natuin account delete\n```\n\nThis will delete your account, and all history from the remote server. It will not delete your local data.\n\n## I've forgotten my password! How can I reset it?\n\nWe don't currently have a password reset system. So long as you're still logged\nin on at least one machine, it's safe to delete and re-create your account.\n\n## I did not set up sync, and now I have to reinstall my system!\n\nIf you have a backup of `~/.local/share/atuin`, you can import it by:\n1. disabling atuin by commenting out the shell integration, e.g. for bash it's `eval \"$(atuin init bash)\"`\n2. copying the backup to `~/.local/share/atuin`\n3. reenabling atuin\n4. setting up sync!\n\n## Alternative projects\n\nIf you don't like atuin, perhaps one of these works better for you:\n\n- https://github.com/ddworken/hishtory\n  - written in go\n  - also provides sync'ed history\n- https://github.com/cantino/mcfly\n  - uses a small local neural network for search\n  - only local history\n"
  },
  {
    "path": "docs/docs/guide/advanced-usage.md",
    "content": "# Advanced Usage\n\nAtuin offers you several options to help navigate through the results.\n\n## Filter mode\n\nThe command history can be filtered in different ways, letting you narrow the search scope.\n\nYou can cycle through the different modes by pressing **ctrl-r**.\n\nThe available modes are:\n\n| Mode             | Description                                                                          |\n|------------------|--------------------------------------------------------------------------------------|\n| global (default) | Search from the full history                                                         |\n| host             | Search history from this host                                                        |\n| session          | Search history from the current session                                              |\n| directory        | Search history from the current directory                                            |\n| workspace        | Search history from the current git repository                                       |\n| session-preload  | Search from the current session and the global history from before the session start |\n\nSee the [`filter_mode` config reference](../configuration/config.md#filter_mode) for more details.\n\n## Search mode\n\nAtuin offers different modes to interpret your search query.\n\nYou can cycle through the different modes by pressing **ctrl-s**.\n\nThe available modes are:\n\n| Mode            | Description                                                                                                    |\n|-----------------|----------------------------------------------------------------------------------------------------------------|\n| fuzzy (default) | Search for commands in a fuzzy way, similar to the [fzf syntax](https://github.com/junegunn/fzf#search-syntax) |\n| prefix          | Commands that start with your query                                                                            |\n| fulltext        | Commands that contain your query as a substring                                                                |\n| skim            | Search for commands using the [skim syntax](https://github.com/lotabout/skim#search-syntax)                    |\n\nSee the [`search_mode` config reference](../configuration/config.md#search_mode) for more details.\n\n## Context switch\n\nAtuin uses the current context (host, session, directory) to filter the history when you use a filter mode other than *global*.\n\nYou can switch this context to the one of the currently selected command by pressing **ctrl-a** then **c**.\n\nThis will set the filter mode to *session* and clear the search query, which will show you all the commands executed in the same shell session.\n\nPressing this key combination again will return to the initial context. You can customize this behavior by setting [custom key bindings](../configuration/advanced-key-binding.md) to the `switch-context` and `clear-context` commands. `switch-context` can be called several times to navigate through multiple command contexts, while `clear-context` will always return to the initial context.\n"
  },
  {
    "path": "docs/docs/guide/basic-usage.md",
    "content": "# Basic Usage\n\nNow that you're all set up and running, here's a quick walkthrough of how you can use Atuin best.\n\n## What does Atuin record?\n\nWhile you work, Atuin records:\n\n1. The command you run\n2. The directory you ran it in\n3. The time you ran it, and how long it took to run\n4. The exit code of the command\n5. The hostname + user of the machine\n6. The shell session you ran it in\n\n## Opening and using the TUI\n\nAt any time, you can open the TUI with the default keybindings of the up arrow, or ctrl-r.\n\nOnce in the TUI, press enter to immediately execute a command, or press tab to insert it into your shell for editing.\n\nWhile searching in the TUI, you can adjust the \"filter mode\" by repeatedly pressing ctrl-r. Atuin can filter by:\n\n1. All hosts\n2. Just your local machine\n3. The current directory only\n4. The current shell session only\n\nSee the [advanced usage](advanced-usage.md) page for more options.\n\n## Common config adjustment\n\nFor a full set of config values, please see the [config reference page](../configuration/config.md).\n\nThe default configuration file is located at `~/.config/atuin/config.toml`.\n\n### Keybindings\n\nWe have a [full page dedicated to keybinding adjustments](../configuration/key-binding.md).\nThere are a whole bunch of options there, including disabling the up arrow behavior if you don't like it.\n\n### Enter to run\n\nYou may prefer that Atuin always inserts the selected command for editing. To configure this, set\n\n```\nenter_accept = false\n```\n\nin your config file.\n\n### Inline window\n\nIf you find the full screen TUI overwhelming or too large, you can adjust it like so:\n\n```\n# height of the search window\ninline_height = 40\n```\n\nYou may also prefer the compact UI mode:\n\n```\nstyle = \"compact\"\n```\n"
  },
  {
    "path": "docs/docs/guide/delete-history.md",
    "content": "# Deleting History\n\nAtuin provides several ways to delete history, whether you want to remove a single entry, bulk delete by query, clean up duplicates, or wipe everything.\n\nAll deletion methods are local-first. If you have sync enabled, deletions are propagated to other machines automatically.\n\n## Deleting a single entry\n\nThe quickest way to delete a single entry is via the interactive TUI.\n\n### Using the inspector\n\n1. Open the TUI with ++ctrl+r++ or the up arrow\n2. Search for the entry you want to delete\n3. Press ++ctrl+o++ to open the inspector on the selected entry\n4. Verify this is the correct entry\n5. Press ++ctrl+d++ to delete it\n\n### Using the prefix shortcut\n\n1. Open the TUI with ++ctrl+r++ or the up arrow\n2. Navigate to the entry you want to delete\n3. Press ++ctrl+a++ then ++d++ to delete the selected entry\n\nBoth methods remove the entry immediately with no further confirmation.\n\n## Deleting entries matching a query\n\nUse `atuin search --delete` to delete all entries matching a search query. This uses the same query syntax as regular search, so you can preview what will be deleted before committing.\n\n### Preview first, then delete\n\nAlways run your query without `--delete` first to verify the results:\n\n```\n# Step 1: preview - see what matches\natuin search \"^curl https://internal\"\n\n# Step 2: delete - once you're satisfied the results are correct\natuin search --delete \"^curl https://internal\"\n```\n\n### Combining filters\n\nYou can combine `--delete` with any search filter:\n\n```\n# Delete all failed commands run from a specific directory\natuin search --delete --exit 1 --cwd /home/user/experiments\n\n# Delete commands matching a pattern that ran before a certain date\natuin search --delete --before \"2024-01-01\" \"^tmp-script\"\n\n# Delete successful cargo commands run after yesterday at 3pm\natuin search --delete --exit 0 --after \"yesterday 3pm\" cargo\n```\n\n!!! warning\n    `--delete` requires a query or filter. It will not run without one. This is intentional to prevent accidental bulk deletion.\n\n## Deleting all history\n\nIf you want to wipe your entire local history:\n\n```\natuin search --delete-it-all\n```\n\n!!! danger\n    This deletes every entry in your local history database. It cannot be combined with a query or filters. This action is irreversible.\n\n### Starting fresh with sync\n\nIf you use sync and want to start completely fresh, `--delete-it-all` alone is not enough. Atuin sync works by recording every action (including deletions) as encrypted records. Deleting 100,000 entries locally creates 100,000 delete records that still need to sync. When your other machines pull those records, they process every single one, and your database still contains the overhead of all that history.\n\nThe cleaner approach is to delete your sync account and start over:\n\n```\n# Delete your sync account and all server-side data\natuin account delete\n\n# Register a new account\natuin register\n\n# Import your shell history fresh (optional)\natuin import auto\n```\n\nThis gives you a clean slate on the server with no leftover records. Your other machines can then register with the new account and start fresh too.\n\n!!! tip\n    If you only want to delete specific entries and keep the rest, `atuin search --delete` is the right tool. The account reset approach is only better when you want to wipe everything and start over.\n\n## Pruning filtered commands\n\nIf you've updated your [`history_filter`](../configuration/config.md#history_filter) config and want to retroactively remove entries that match the new filters:\n\n```\n# Preview what will be removed\natuin history prune --dry-run\n\n# Perform the deletion\natuin history prune\n```\n\nThis is useful when you add a new pattern to `history_filter` - future commands matching the filter are never recorded, but old entries that were recorded before the filter was set up remain. `prune` cleans those up.\n\n## Deduplicating history\n\nRemove duplicate entries (same command, working directory, and hostname):\n\n```\n# Preview duplicates that would be removed\natuin history dedup --dry-run --before \"2025-01-01\" --dupkeep 1\n\n# Delete them\natuin history dedup --before \"2025-01-01\" --dupkeep 1\n```\n\n| Flag | Description |\n|------|-------------|\n| `--dry-run`/`-n` | List duplicates without deleting |\n| `--before`/`-b` | Only consider entries added before this date (required) |\n| `--dupkeep` | Number of recent duplicates to keep |\n\n## Deleting your sync account\n\nTo delete your remote sync account and all server-side history:\n\n```\natuin account delete\n```\n\nThis removes your account and all synchronized history from the server. **Local history is not affected.** See the [sync reference](../reference/sync.md) for more details.\n\n## Summary\n\n| Goal | Command |\n|------|---------|\n| Delete one entry (TUI) | ++ctrl+o++ then ++ctrl+d++, or ++ctrl+a++ then ++d++ |\n| Delete entries by query | `atuin search --delete <query>` |\n| Delete all history | `atuin search --delete-it-all` |\n| Start fresh (with sync) | `atuin account delete` then re-register |\n| Remove filtered entries | `atuin history prune` |\n| Remove duplicates | `atuin history dedup --before <date> --dupkeep <n>` |\n| Delete sync account | `atuin account delete` |\n"
  },
  {
    "path": "docs/docs/guide/dotfiles.md",
    "content": "# Syncing dotfiles\n\nWhile Atuin started as a tool for syncing and searching shell history, we are\nbuilding tooling for syncing dotfiles across machines, and making them easier\nto work with.\n\nAt the moment, we support managing and syncing of shell aliases and environment variables - with more\ncoming soon.\n\nThe following shells are supported:\n\n- zsh\n- bash\n- fish\n- xonsh\n- powershell\n\nNote: Atuin handles your configuration internally, so once it is installed you\nno longer need to edit your config files manually.\n\n## Required config\n\nOnce Atuin is set up and installed, the following is required in your config file (`~/.config/atuin/config.toml`)\n\n```\n[dotfiles]\nenabled = true\n```\n\nIn a later release, this will be enabled by default.\n\nNote: If you have not yet set up sync v2, please also add\n\n```\n[sync]\nrecords = true\n```\n\nto the same config file.\n\n## Usage\n\n### Aliases\n\nAfter creating or deleting an alias, remember to restart your shell!\n\n#### Creating an alias\n\n```\natuin dotfiles alias set NAME 'COMMAND'\n```\n\nFor example, to alias `k` to be `kubectl`\n\n\n```\natuin dotfiles alias set k 'kubectl'\n```\n\nor to alias `ll` to be  `ls -lah`\n\n```\natuin dotfiles alias set ll 'ls -lah'\n```\n\n#### Deleting an alias\n\nDeleting an alias is as simple as:\n\n```\natuin dotfiles alias delete NAME\n```\n\nFor example, to delete the above alias `k`:\n\n```\natuin dotfiles alias delete k\n```\n\n#### Listing aliases\n\nYou can list all aliases with:\n\n```\natuin dotfiles alias list\n```\n\n### Env vars\n\nAfter creating or deleting an env var, remember to restart your shell!\n\n#### Creating a var\n\n```\natuin dotfiles var set NAME 'value'\n```\n\nFor example, to set `FOO` to be `bar`\n\n\n```\natuin dotfiles var set FOO 'bar'\n```\n\nVars are exported by default, but you can create a shell var like so\n\n```\natuin dotfiles var set -n foo 'bar'\n```\n\n\n#### Deleting a var\n\nDeleting a var is as simple as:\n\n```\natuin dotfiles var delete NAME\n```\n\nFor example, to delete the above var `FOO`:\n\n```\natuin dotfiles var delete FOO\n```\n\n#### Listing vars\n\nYou can list all vars with:\n\n```\natuin dotfiles var list\n```\n\n### Syncing and backing up dotfiles\nIf you have [set up sync](sync.md), then running\n\n```\natuin sync\n```\n\nwill back up your config to the server and sync it across machines.\n"
  },
  {
    "path": "docs/docs/guide/getting-started.md",
    "content": "# Getting Started\n\nAtuin replaces your existing shell history with a SQLite database, and records\nadditional context for your commands. With this context, Atuin gives you faster\nand better search of your shell history.\n\nAdditionally, Atuin (optionally) syncs your shell history between all of your\nmachines. Fully end-to-end encrypted, of course.\n\nYou may use either the server I host, or host your own! Or just don't use sync\nat all. As all history sync is encrypted, I couldn't access your data even if I\nwanted to. And I **really** don't want to.\n\nIf you have any problems, please open an [issue](https://github.com/ellie/atuin/issues) or get in touch on our [Discord](https://discord.gg/Fq8bJSKPHh)!\n\n## Supported Shells\n\n- zsh\n- bash\n- fish\n- nushell\n- xonsh\n- powershell (tier 2 support)\n\n## Quickstart\n\nPlease do try and read this guide, but if you're in a hurry and want to get\nstarted quickly:\n\n```\nbash <(curl --proto '=https' --tlsv1.2 -sSf https://setup.atuin.sh)\n\natuin register -u <USERNAME> -e <EMAIL>\natuin import auto\natuin sync\n```\n\nNow restart your shell!\n\nAnytime you press ctrl-r or up, you will see the Atuin search UI. Type your\nquery, enter to execute. If you'd like to select a command without executing\nit, press tab.\n\nYou might like to configure an [inline window](../configuration/config.md#inline_height), or [disable up arrow bindings](../configuration/key-binding.md#disable-up-arrow)\n\nNote: The above sync and registration is fully optional. If you'd like to use Atuin offline, you can run\n\n```\nbash <(curl --proto '=https' --tlsv1.2 -sSf https://setup.atuin.sh)\n\natuin import auto\n```\n\nYour shell history will be local only, not backed up, and not synchronized across devices.\n"
  },
  {
    "path": "docs/docs/guide/import.md",
    "content": "# Import existing history\n\nAtuin uses a shell plugin to ensure that we capture new shell history. But for\nolder history, you will need to import it\n\nThis will import the history for your current shell:\n```\natuin import auto\n```\n\nAlternatively, you can specify the shell like so:\n\n```\natuin import bash\natuin import zsh # etc\n```\n\nYour old shell history file will continue to be updated, regardless of Atuin usage.\n"
  },
  {
    "path": "docs/docs/guide/installation.md",
    "content": "# Installation\n\n## Recommended installation approach\n\n### On Unix\n\nLet's get started! First up, you will want to install Atuin. The recommended\napproach is to use the installation script, which automatically handles the\ninstallation of Atuin including the requirements for your environment.\n\n\nIt will install a binary to `~/.atuin/bin`, and if you'd rather do something else\nthen the manual steps below offer much more flexibility.\n\n```shell\ncurl --proto '=https' --tlsv1.2 -LsSf https://setup.atuin.sh | sh\n```\n\nThe install script will walk you through importing your shell history and setting\nup a sync account. To skip these interactive prompts (e.g. in CI or\nDockerfiles), pass `--non-interactive`:\n\n```shell\ncurl --proto '=https' --tlsv1.2 -LsSf https://setup.atuin.sh | sh -s -- --non-interactive\n```\n\nThe script also automatically detects non-interactive environments (piped input,\nno TTY) and skips the prompts in those cases.\n\n[**Set up sync** - Move on to the next step, or read on to manually install Atuin instead.](sync.md)\n\n### On Windows\n\nThe recommended approach on Windows is to use WinGet to install Atuin. Then, if you use PowerShell,\nadd the initialization command to your PowerShell profile, and restart your shell.\n\n```shell\nwinget install -e Atuinsh.Atuin\nif (-not (Test-Path -Path $PROFILE)) { New-Item -ItemType File -Path $PROFILE -Force | Out-Null }\nWrite-Output 'atuin init powershell | Out-String | Invoke-Expression' >> $PROFILE\n```\n\nNote that the `$PROFILE` path may depend on your PowerShell version.\n\n[**Set up sync** - Move on to the next step.](sync.md)\n\n## Manual installation\n\n### Installing the binary\n\nIf you don't wish to use the installer, the manual installation steps are as follows.\n\n=== \"Cargo\"\n\n    It's best to use [rustup](https://rustup.rs/) to set up a Rust\n    toolchain, then you can run:\n\n    ```shell\n    cargo install atuin\n    ```\n\n=== \"Homebrew\"\n\n    ```shell\n    brew install atuin\n    ```\n\n=== \"MacPorts\"\n\n    Atuin is also available in [MacPorts](https://ports.macports.org/port/atuin/)\n\n    ```shell\n    sudo port install atuin\n    ```\n\n=== \"mise\"\n\n    Atuin is also installable using [mise](https://github.com/jdx/mise)\n\n    ```shell\n    mise use -g atuin@latest\n    ```\n\n=== \"Nix\"\n\n    This repository is a flake, and can be installed using `nix profile`:\n\n    ```shell\n    nix profile install \"github:atuinsh/atuin\"\n    ```\n\n    Atuin is also available in [nixpkgs](https://github.com/NixOS/nixpkgs):\n\n    ```shell\n    nix-env -f '<nixpkgs>' -iA atuin\n    ```\n\n=== \"Pacman\"\n\n    Atuin is available in the Arch Linux [extra repository](https://archlinux.org/packages/extra/x86_64/atuin/):\n\n    ```shell\n    pacman -S atuin\n    ```\n\n=== \"XBPS\"\n\n    Atuin is available in the Void Linux [repository](https://github.com/void-linux/void-packages/tree/master/srcpkgs/atuin):\n\n    ```shell\n    sudo xbps-install atuin\n    ```\n\n=== \"Termux\"\n\n    Atuin is available in the Termux package repository:\n\n    ```shell\n    pkg install atuin\n    ```\n\n=== \"zinit\"\n\n    Atuin is installable from github-releases directly:\n\n    ```shell\n    # line 1: `atuin` binary as command, from github release, only look at .tar.gz files, use the `atuin` file from the extracted archive\n    # line 2: setup at clone(create init.zsh, completion)\n    # line 3: pull behavior same as clone, source init.zsh\n    zinit ice as\"command\" from\"gh-r\" bpick\"atuin-*.tar.gz\" mv\"atuin*/atuin -> atuin\" \\\n        atclone\"./atuin init zsh > init.zsh; ./atuin gen-completions --shell zsh > _atuin\" \\\n        atpull\"%atclone\" src\"init.zsh\"\n    zinit light atuinsh/atuin\n    ```\n\n=== \"WinGet\"\n\n    Atuin is available on WinGet:\n\n    ```shell\n    winget install -e Atuinsh.Atuin\n    ```\n\n=== \"Source\"\n\n    Atuin builds on the latest stable version of Rust, and we make no\n    promises regarding older versions. We recommend using [rustup](https://rustup.rs/).\n\n    ```shell\n    git clone https://github.com/atuinsh/atuin.git\n    cd atuin/crates/atuin\n    cargo install --path .\n    ```\n\n!!! warning \"Please be advised\"\n\n    If you choose to manually install Atuin rather than using the recommended installation script,\n    merely installing the binary is not sufficient, you should also set up the shell plugin.\n\n---\n\n### Installing the shell plugin\n\nOnce the binary is installed, the shell plugin requires installing.\nIf you use the install script, this should all be done for you!\nAfter installing, remember to restart your shell.\n\n=== \"zsh\"\n\n    ```shell\n    echo 'eval \"$(atuin init zsh)\"' >> ~/.zshrc\n    ```\n\n    === \"zinit\"\n\n        ```shell\n        # if you _only_ want to install the shell-plugin, do this; otherwise look above for a \"everything via zinit\" solution\n        zinit load atuinsh/atuin\n        ```\n\n    === \"Antigen\"\n\n        ```shell\n        antigen bundle atuinsh/atuin@main\n        ```\n\n    === \"Antidote\"\n\n        ```shell\n        antidote install atuinsh/atuin\n        ```\n\n=== \"bash\"\n\n    === \"ble.sh\"\n\n        Atuin works best in bash when using [ble.sh](https://github.com/akinomyoga/ble.sh) >= 0.4.\n\n        With ble.sh (>= 0.4) installed and loaded in `~/.bashrc`, just add atuin to your `~/.bashrc`\n\n        ```shell\n        echo 'eval \"$(atuin init bash)\"' >> ~/.bashrc\n        ```\n\n    === \"bash-preexec\"\n\n        [Bash-preexec](https://github.com/rcaloras/bash-preexec) can also be used, but you may experience\n         some minor problems with the recorded duration and exit status of some commands.\n\n        !!! warning \"Please note\"\n\n            bash-preexec currently has an issue where it will stop honoring `ignorespace`.\n            While Atuin will ignore commands prefixed with whitespace, they may still end up in your bash history.\n            Please check your configuration! All other shells do not have this issue.\n\n            To use `atuin < 18.10.0` in `bash < 4` with bash-preexec, the option\n            `enter_accept` needs to be turned on (which is so by default).  There is no\n            restriction in the latest version of Atuin (>= 18.10.0).\n\n            bash-preexec cannot properly invoke the `preexec` hook for subshell commands\n            `(...)`, function definitions `func() { ...; }`, empty for-in-statements `for\n            i in; do ...; done`, etc., so those commands and duration may not be recorded\n            in the Atuin's history correctly.\n\n        To use bash-preexec, download and initialize it\n\n        ```shell\n        curl https://raw.githubusercontent.com/rcaloras/bash-preexec/master/bash-preexec.sh -o ~/.bash-preexec.sh\n        echo '[[ -f ~/.bash-preexec.sh ]] && source ~/.bash-preexec.sh' >> ~/.bashrc\n        ```\n\n        Then set up Atuin\n\n        ```shell\n        echo 'eval \"$(atuin init bash)\"' >> ~/.bashrc\n        ```\n\n=== \"fish\"\n\n    Add\n\n    ```shell\n    atuin init fish | source\n    ```\n\n    to your `is-interactive` block in your `~/.config/fish/config.fish` file\n\n=== \"Nushell\"\n\n    Run in *Nushell*:\n\n    ```shell\n    mkdir ~/.local/share/atuin/\n    atuin init nu | save ~/.local/share/atuin/init.nu\n    ```\n\n    Add to `config.nu`:\n\n    ```shell\n    source ~/.local/share/atuin/init.nu\n    ```\n\n=== \"xonsh\"\n\n    Add\n    ```shell\n    execx($(atuin init xonsh))\n    ```\n    to the end of your `~/.xonshrc`\n\n=== \"PowerShell\"\n\n    Add the following to the end of your `$PROFILE` file:\n\n    ```shell\n    atuin init powershell | Out-String | Invoke-Expression\n    ```\n\n## Upgrade\n\nRun `atuin update`, and if that command is not available, run the install script again.\n\nIf you used a package manager to install Atuin, then you should also use your package manager to update Atuin.\n\n## Uninstall\n\nIf you'd like to uninstall Atuin, please check out [the uninstall page](../uninstall.md).\n"
  },
  {
    "path": "docs/docs/guide/shell-integration.md",
    "content": "# Shell Integration and Interoperability\n\nAtuin uses shell hooks to capture your command history. This page explains how the integration works, why Atuin might not record commands in certain environments, and how to control what gets recorded.\n\n## How Atuin's Shell Integration Works\n\nWhen you add `eval \"$(atuin init <shell>)\"` to your shell configuration, Atuin installs hooks that run at specific points in your shell's command lifecycle:\n\n1. **Preexec hook**: Runs *before* each command executes. Atuin records the command text, timestamp, and working directory.\n2. **Precmd hook**: Runs *after* each command completes. Atuin records the exit code and duration.\n\nThese hooks only activate under specific conditions:\n\n- The shell must be **interactive** (started with `-i` or inherently interactive)\n- Your shell configuration file must be **sourced** (`.bashrc`, `.zshrc`, etc.)\n- The `atuin init` command must run during shell startup\n\nIf any of these conditions aren't met, Atuin's hooks won't be installed, and commands won't be recorded.\n\n### Environment Variables\n\nWhen Atuin initializes, it sets several environment variables:\n\n| Variable | Purpose |\n|----------|---------|\n| `ATUIN_SESSION` | Unique identifier for this shell session |\n| `ATUIN_SHLVL` | Tracks shell nesting level |\n| `ATUIN_HISTORY_ID` | Temporary ID for the currently executing command |\n| `ATUIN_HISTORY_AUTHOR` | Optional command author identity (for example `ellie`, `claude`, `copilot`) |\n| `ATUIN_HISTORY_INTENT` | Optional command intent/rationale text |\n\nThese variables are used internally to track command execution and associate commands with sessions.\nIf `ATUIN_HISTORY_AUTHOR` is not set, Atuin defaults to the local shell username.\n\n## Embedded Terminals and IDE Integrations\n\nMany development tools include embedded terminals:\n\n- **IDEs**: PyCharm, IntelliJ, VS Code, Cursor, Zed\n- **AI coding assistants**: Claude Code, GitHub Copilot CLI, Aider\n- **Container environments**: Docker, Podman, devcontainers\n\nThese tools often spawn shells differently than your regular terminal, which can prevent Atuin from working.\n\n### Why Atuin Might Not Work\n\nEmbedded terminals commonly:\n\n1. **Start non-interactive shells**: Many tools run commands via `bash -c \"command\"` or similar, which doesn't trigger shell configuration\n2. **Skip shell configuration**: Some tools explicitly avoid sourcing `.bashrc`/`.zshrc` for performance or isolation\n3. **Use different shell paths**: The embedded terminal might use a different shell than your default\n\nYou can verify whether Atuin is active by running:\n\n```shell\natuin doctor\n```\n\nLook for the `shell.preexec` field in the output. If it shows `none`, Atuin's hooks aren't installed in that shell session.\n\n### Enabling Atuin in Embedded Terminals\n\nIf you want Atuin to record commands from an embedded terminal, you'll need to ensure it starts an interactive shell that sources your configuration.\n\n#### IDE Terminal Settings\n\nMost IDEs let you customize the shell command used for their integrated terminal:\n\n**PyCharm / IntelliJ:**\n\n1. Go to Settings → Tools → Terminal\n2. Change \"Shell path\" to include the `-i` flag:\n   - Linux/macOS: `/bin/bash -i` or `/bin/zsh -i`\n   - Or create a wrapper script (see below)\n\n**VS Code:**\n\nAdd to your `settings.json` (substitute the shells for whatever you use):\n\n```json\n{\n  \"terminal.integrated.profiles.linux\": {\n    \"bash\": {\n      \"path\": \"/bin/bash\",\n      \"args\": [\"-i\"]\n    }\n  },\n  \"terminal.integrated.profiles.osx\": {\n    \"zsh\": {\n      \"path\": \"/bin/zsh\",\n      \"args\": [\"-i\"]\n    }\n  }\n}\n```\n\n#### Wrapper Script Approach\n\nFor tools that don't easily support shell arguments, create a wrapper script:\n\n```shell\n#!/bin/bash\n# Save as ~/bin/interactive-bash.sh and chmod +x\nexec /bin/bash -i \"$@\"\n```\n\nThen configure your IDE to use `~/bin/interactive-bash.sh` as the shell path.\n\n#### Verifying the Fix\n\nAfter configuring, open a new terminal in your IDE and run:\n\n```shell\natuin doctor | grep preexec\n```\n\nYou should see `built-in`, `bash-preexec`, `blesh`, or similar—not `none`.\n\n## Excluding Commands from History\n\nSometimes you *don't* want certain commands in your history. This is common when:\n\n- AI coding tools run many automated commands (git status, file listings, etc.)\n- You're running sensitive commands you don't want synced\n- Build tools or scripts generate repetitive command noise\n\n### Using history_filter\n\nThe `history_filter` option in `~/.config/atuin/config.toml` lets you exclude commands matching specific patterns:\n\n```toml\nhistory_filter = [\n    \"^ls$\",           # Exclude bare 'ls' commands\n    \"^cd \",           # Exclude cd commands\n    \"^cat \",          # Exclude cat commands\n]\n```\n\nPatterns are regular expressions. They're unanchored by default, so `secret` matches anywhere in a command. Use `^` and `$` for exact matching.\n\n### Using cwd_filter\n\nTo exclude all commands run from specific directories:\n\n```toml\ncwd_filter = [\n    \"^/tmp\",                    # Exclude commands run from /tmp\n    \"/node_modules/\",           # Exclude commands from any node_modules\n    \"^/home/user/scratch\",      # Exclude a scratch directory\n]\n```\n\n### Prefix with Space (ignorespace)\n\nMost shells support \"ignorespace\"—commands prefixed with a space aren't saved to history. Atuin honors this convention:\n\n```shell\n echo \"this won't be saved\"  # Note the leading space\n```\n\n!!! warning \"Bash with bash-preexec\"\n    When using bash-preexec (not ble.sh), there's a known issue where ignorespace isn't fully honored. The command won't appear in Atuin, but may still appear in your bash history. See [installation](installation.md) for details.\n\n### Disabling Atuin for Specific Tools\n\nIf a tool spawns interactive shells but you don't want its commands recorded, you have several options:\n\n#### Option 1: Environment Variable Check\n\nModify your shell configuration to skip Atuin initialization based on an environment variable:\n\n```shell\n# In .bashrc or .zshrc\nif [[ -z \"${MY_TOOL_SESSION}\" ]]; then\n    eval \"$(atuin init bash)\"\nfi\n```\n\nThen configure your tool to set `MY_TOOL_SESSION=1` when spawning shells.\n\n#### Option 2: Use history_filter for Tool-Specific Patterns\n\nIf the tool runs predictable commands, filter them:\n\n```toml\nhistory_filter = [\n    \"^git status$\",\n    \"^git diff\",\n    \"^ls -la$\",\n]\n```\n\n#### Option 3: Filter by Directory\n\nIf the tool operates in specific directories:\n\n```toml\ncwd_filter = [\n    \"^/path/to/tool/workspace\",\n]\n```\n\n### Cleaning Up Existing History\n\nAfter adding filters, you can remove matching entries from your existing history:\n\n```shell\natuin history prune\n```\n\nThis removes entries that match your current `history_filter` or `cwd_filter` patterns.\n\n## Shell-Specific Notes\n\n### Bash\n\nAtuin supports two preexec backends for Bash:\n\n- **ble.sh** (recommended): Full-featured line editor with accurate timing and proper ignorespace support\n- **bash-preexec**: Simpler but has some limitations with subshells and ignorespace\n\nThe shell integration explicitly checks for interactive mode:\n\n```bash\nif [[ $- != *i* ]]; then\n    # Not interactive, skip initialization\n    return\nfi\n```\n\n### Zsh\n\nZsh has native hook support via `add-zsh-hook`. The integration is straightforward and works reliably in interactive sessions.\n\n### Fish\n\nFish uses its event system (`fish_preexec` and `fish_postexec` events). It also respects Fish's private mode—commands run with `fish --private` aren't recorded.\n\n## Troubleshooting\n\n### Commands aren't being recorded\n\n1. Run `atuin doctor` and check the output\n2. Verify `shell.preexec` is not `none`\n3. Ensure your shell is interactive (`echo $-` should contain `i`)\n4. Check that `atuin init` is in your shell config and being sourced\n\n### Commands from a specific tool aren't recorded\n\n1. Check if the tool starts an interactive shell\n2. Try configuring the tool to use `bash -i` or `zsh -i`\n3. Use a wrapper script if the tool doesn't support shell arguments\n\n### Too many commands are being recorded\n\n1. Add patterns to `history_filter` in your config\n2. Use `cwd_filter` for directory-based exclusion\n3. Prefix sensitive commands with a space\n4. Run `atuin history prune` to clean existing entries\n\n### Atuin works in terminal but not in IDE\n\nThis is the most common issue. The IDE's embedded terminal likely isn't starting an interactive shell. See [Enabling Atuin in Embedded Terminals](#enabling-atuin-in-embedded-terminals) above.\n"
  },
  {
    "path": "docs/docs/guide/sync.md",
    "content": "# Setting up Sync\n\nAt this point, you have Atuin storing and searching your shell history! But it\nisn't syncing it just yet. To do so, you'll need to register with the sync\nserver. All of your history is fully end-to-end encrypted, so there are no\nrisks of the server snooping on you.\n\nIf you don't have an account, please [register](#register). If you have already registered,\nproceed to [login](#login).\n\n**Note:** You first have to set up your `sync_address` if you want to use a [self hosted server](../self-hosting/server-setup.md).\n\n## Register\n\n```\natuin register -u <YOUR_USERNAME> -e <YOUR EMAIL>\n```\n\nAfter registration, Atuin will generate an encryption key for you and store it\nlocally. This is needed for logging in to other machines, and can be seen with\n\n```\natuin key\n```\n\nPlease **never** share this key with anyone! The Atuin developers will never\nask you for your key, your password, or the contents of your Atuin directory.\n\nIf you lose your key, we can do nothing to help you. We recommend you store\nthis somewhere safe, such as in a password manager.\n\n## First sync\nBy default, Atuin will sync your history once per hour. This can be\n[configured](../configuration/config.md#sync_frequency).\n\nTo run a sync manually, please run\n\n```\natuin sync\n```\n\nAtuin tries to be smart with a sync, and not waste data transfer. However, if\nyou are seeing some missing data, please try running\n\n```\natuin sync -f\n```\n\nThis triggers a full sync, which may take longer as it works through historical data.\n\n## Login\n\nWhen only signed in on one machine, Atuin sync operates as a backup. This is\npretty useful by itself, but syncing multiple machines is where the magic\nhappens.\n\nFirst, ensure you are [registered with the sync server](#register) and make a\nnote of your key. You can see this with `atuin key`.\n\nThen, install Atuin on a new machine. Once installed, login with\n\n```\natuin login -u <USERNAME>\n```\n\nYou will be prompted for your password, and for your key.\n\nSyncing will happen automatically in the background, but you may wish to run it manually with\n\n```\natuin sync\n```\n\nOr, if you see missing data, force a full sync with:\n\n```\natuin sync -f\n```\n"
  },
  {
    "path": "docs/docs/guide/theming.md",
    "content": "# Theming\n\nAvailable in Atuin >= 18.4\n\nFor terminal interface customization, Atuin supports user and built-in color themes.\n\nAtuin ships with only a couple of built-in alternative themes, but more can be added via TOML files.\n\n## Required config\n\nThe following is required in your config file (`~/.config/atuin/config.toml`)\n\n```\n[theme]\nname = \"THEMENAME\"\n```\n\nWhere `THEMENAME` is a known theme. The following themes are available out-of-the-box:\n\n* `default` theme\n* `autumn` theme\n* `marine` theme\n* `(none)` theme (removes all styling)\n\nThese are present to ensure users and developers can try out theming, but in general, you\nwill need to download themes or make your own.\n\nIf you are writing your own themes, you can add the following line to get additional output:\n\n```\ndebug = true\n```\n\nto the same config block. This will print out any color names that cannot be parsed from\nthe requested theme.\n\nA final optional setting is available:\n\n```\nmax_depth: 10\n```\n\nwhich sets the maximum levels of theme parents to traverse. This should not need to be\nexplicitly added in normal use.\n\n## Usage\n\n### Theme structure\n\nThemes are maps from *Meanings*, describing the developer's intentions,\nto (at present) colors. In future, this may be expanded to allow richer style support.\n\n*Meanings* are from an enum with the following values:\n\n* `AlertInfo`: alerting the user at an INFO level\n* `AlertWarn`: alerting the user at a WARN level\n* `AlertError`: alerting the user at an ERROR level\n* `Annotation`: less-critical, supporting text\n* `Base`: default foreground color\n* `Guidance`: instructing the user as help or context\n* `Important`: drawing the user's attention to information\n* `Title`: titling a section or view\n* `Muted`: anodyne, usually grey, foreground for contrast with other colors. Normally equivalent to the base color, but allows themes to change the base color, with less risk of breaking intentional color contrasts (e.g. stacked bar charts)\n\nThese may expand over time as they are added to Atuin's codebase, but Atuin\nshould have fallbacks added for any new *Meanings* so that, whether themes limit to\nthe present list or take advantage of new *Meanings* in future, they should\nkeep working sensibly.\n\n**Note for Atuin contributors**: please do identify and, where appropriate during your own\nPRs, extend the Meanings enum if needed (along with a fallback Meaning!).\n\n### Theme creation\n\nWhen a theme name is read but not yet loaded, Atuin will look for it in the folder\n`~/.config/atuin/themes/` unless overridden by the `ATUIN_THEME_DIR` environment\nvariable. It will attempt to open a file of name `THEMENAME.toml` and read it as a\nmap from *Meanings* to foreground colors.\n\nNote that, at present, it is not possible to specify the default terminal color explicitly\nin a theme file. However, the default theme Base color will always be unset and therefore\nwill be the user's default terminal color. Hence, you should only override the Base color\nin your theme, or derive from a theme that does so, if your theme would not make sense\notherwise (e.g. the `marine` theme is intended to make everything green/blue, so it does,\nbut the `autumn` theme only seeks to make the custom colors warmer, so it does not).\n\nColors may be specified either as names from the [palette](https://ogeon.github.io/docs/palette/master/palette/named/index.html)\ncrate in lowercase, or as six-character hex codes, prefixed with `#`. To explicitly select ANSI colors by integer, or for greater flexibility in general, you can prefix with `@` and the rest of the string will be handled by crossterm's color parsing. For examples, see [crossterm's color deserialization tests](https://github.com/crossterm-rs/crossterm/blob/5d50d8da62c5e034ef8b2787a771a2c0f9b3b2f9/src/style/types/color.rs#L389), remembering the need to add a `@` prefix for atuin.\n\nFor example, the following are valid color names:\n\n* `#ff0088`\n* `teal`\n* `powderblue`\n* `@ansi_(255)`\n* `@rgb_(255, 128, 0)`\n\nYou can also express colors through Crossterm-supported strings, prefixed by `@`.\nFor example,\n\n* `@ansi_(123)`\n* `@dark_yellow`\n\nWhile there is not currently an official reference, you can see examples in the\n[crossterm tests](https://docs.rs/crossterm/latest/src/crossterm/style/types/color.rs.html#376).\nAs this is passed straight to Crossterm, using [ANSI codes](https://www.ditig.com/256-colors-cheat-sheet)\ncan be helpful for ensuring your theme is compatible with 256-color terminals.\n\nA theme file, say `my-theme.toml` can then be built up, such as:\n\n```toml\n[theme]\nname = \"my-theme\"\nparent = \"autumn\"\n\n[colors]\nAlertInfo = \"green\"\nGuidance = \"#888844\"\n\n```\n\nwhere not all of the *Meanings* need to be explicitly defined. If they are absent,\nthen the color will be chosen from the parent theme, if one is defined, or if that\nkey is missing in the `theme` block, from the `default` theme.\n\nIf the entire named theme is missing, which is inherently an error, then the theme\nwill drop to `(none)` and leave Atuin unstyled, rather than trying to fallback to\nthe any default, or other, theme.\n\nThis theme file should be moved to `~/.config/atuin/themes/my-theme.toml` and the\nfollowing added to `~/.config/atuin/config.toml`:\n\n```\n[theme]\nname = \"my-theme\"\n```\n\nWhen you next run Atuin, your theme should be applied.\n"
  },
  {
    "path": "docs/docs/index.md",
    "content": "# Getting started\n\nAtuin replaces your existing shell history with a SQLite database, and records\nadditional context for your commands. With this context, Atuin gives you faster\nand better search of your shell history.\n\nAdditionally, Atuin (optionally) syncs your shell history between all of your\nmachines. Fully end-to-end encrypted, of course.\n\nYou may use either the server I host, or host your own! Or just don't use sync\nat all. As all history sync is encrypted, I couldn't access your data even if I\nwanted to. And I **really** don't want to.\n\nIf you have any problems, please open a topic on the [forum](https://forum.atuin.sh)\n\nAlternatively, get in touch on our [Discord](https://discord.gg/Fq8bJSKPHh) or open an [issue](https://github.com/atuinsh/atuin/issues)\n\n#### Supported Shells\n\n- zsh\n- bash\n- fish\n- nushell\n- xonsh\n- powershell (tier 2 support)\n\n## Quickstart\n\nPlease do try and read this guide, but if you're in a hurry and want to get\nstarted quickly:\n\n```bash\nbash <(curl --proto '=https' --tlsv1.2 -sSf https://setup.atuin.sh)\n\natuin register -u <USERNAME> -e <EMAIL>\natuin import auto\natuin sync\n```\n\nNow restart your shell!\n\nAnytime you press ctrl-r or up, you will see the Atuin search UI. Type your\nquery, enter to execute. If you'd like to select a command without executing\nit, press tab.\n\nYou might like to configure an [inline window](configuration/config.md#inline_height), or [disable up arrow bindings](configuration/key-binding.md#disable-up-arrow)\n\n[**Installation** - Install and setup Atuin](guide/installation.md)\n"
  },
  {
    "path": "docs/docs/integrations.md",
    "content": "# Integrations\n\nThis page covers integrations with shell plugins and tools. For information about how Atuin's shell hooks work and troubleshooting embedded terminals (IDEs, AI coding assistants, etc.), see [Shell Integration and Interoperability](guide/shell-integration.md).\n\n## zsh-autosuggestions\n\nAtuin automatically adds itself as an [autosuggest strategy](https://github.com/zsh-users/zsh-autosuggestions#suggestion-strategy).\n\nIf you'd like to override this, add your own config after `\"$(atuin init zsh)\"` in your `.zshrc`.\n\n## zsh-vi-mode\n\nIf you are using [Zsh Vi Mode](https://github.com/jeffreytse/zsh-vi-mode), you may want to add the following to your `.zshrc` to prevent overriding the default atuin binds:\n\n```shell\n# Append a command directly (after sourcing zvm)\nzvm_after_init_commands+=(\n  'eval \"$(atuin init zsh)\"'\n)\n```\n\n## ble.sh auto-complete (Bash)\n\nIf ble.sh is available when Atuin's integration is loaded in Bash, Atuin automatically defines and registers an auto-complete source for the autosuggestion feature of ble.sh.\n\nIf you'd like to change the behavior, please overwrite the shell function `ble/complete/auto-complete/source:atuin-history` after `eval \"$(atuin init bash)\"` in your `.bashrc`.\n\nIf you would not like Atuin's auto-complete source, please add the following setting after `eval \"$(atuin init bash)\"` in your `.bashrc`:\n\n```shell\n# bashrc (after eval \"$(atuin init bash)\")\n\nble/util/import/eval-after-load core-complete '\n  ble/array#remove _ble_complete_auto_source atuin-history'\n```\n\n## Embedded Terminals and IDEs\n\nAtuin may not work out of the box in embedded terminals found in IDEs (PyCharm, VS Code, etc.) or AI coding assistants (Claude Code, etc.). This is because these tools often start non-interactive shells that don't source your shell configuration.\n\nFor solutions and workarounds, see [Shell Integration and Interoperability](guide/shell-integration.md).\n"
  },
  {
    "path": "docs/docs/known-issues.md",
    "content": "# Known Issues\n\n- SQLite has some issues with ZFS in certain configurations. As Atuin uses SQLite, this may cause your shell to become slow! We have an [issue](https://github.com/atuinsh/atuin/issues/952) to track, with some workarounds\n- SQLite also does not tend to like network filesystems (eg, NFS)\n"
  },
  {
    "path": "docs/docs/reference/daemon.md",
    "content": "# daemon\n\n## `atuin daemon`\n\nThe Atuin daemon is a background daemon designed to\n\n1. Speed up database writes\n2. Allow machines to sync when not in use, so they're ready to go right away\n3. Provides a hot in-memory fuzzy searcher\n4. Perform background maintenance\n\nIt may also work around issues with ZFS/SQLite performance.\n\n## To enable\n\nAdd the following to the bottom of your Atuin config file\n\n```toml\n[daemon]\nenabled = true\nautostart = true\n```\n\nWith `autostart = true`, the CLI will automatically start and manage a local daemon for history hook calls.\nIf you use systemd socket activation, keep `autostart = false`.\nIf a legacy experimental daemon is already running, autostart cannot upgrade it in-place. Restart the daemon manually once.\n\nIf you prefer running the daemon yourself (for example via systemd/tmux), keep `autostart = false` and run `atuin daemon`.\n\n## Extra config\n\nSee the [config section](../configuration/config.md#daemon)\n"
  },
  {
    "path": "docs/docs/reference/doctor.md",
    "content": "# doctor\n\n## `atuin doctor`\n\nThis command will attempt to diagnose common problems. It will also dump information about your system\n\nPlease include its output with issues and support requests.\n\nExample output:\n\n```\nAtuin Doctor\nChecking for diagnostics\n\n\nPlease include the output below with any bug reports or issues\n\natuin:\n  version: 18.1.0\n  sync:\n    cloud: true\n    records: true\n    auto_sync: true\n    last_sync: 2024-03-05 14:54:48.447677 +00:00:00\nshell:\n  name: zsh\n  plugins:\n  - atuin\nsystem:\n  os: Darwin\n  arch: arm64\n  version: 14.4\n  disks:\n  - name: Macintosh HD\n    filesystem: apfs\n  - name: Macintosh HD\n    filesystem: apfs\n```\n"
  },
  {
    "path": "docs/docs/reference/gen-completions.md",
    "content": "# gen-completions\n\n[Shell completions](https://en.wikipedia.org/wiki/Command-line_completion) for Atuin can be generated by specifying the output directory and desired shell via `gen-completions` subcommand.\n\n```\n$ atuin gen-completions --shell bash --out-dir $HOME\n\nShell completion for BASH is generated in \"/home/user\"\n```\n\nPossible values for the `--shell` argument are the following:\n\n- `bash`\n- `fish`\n- `zsh`\n- `nushell`\n- `powershell`\n- `elvish`\n\nAlso, see the [supported shells](https://github.com/atuinsh/atuin#supported-shells).\n"
  },
  {
    "path": "docs/docs/reference/import.md",
    "content": "# import\n\n## `atuin import`\n\nAtuin can import your history from your \"old\" history file\n\n`atuin import auto` will attempt to figure out your shell (via \\$SHELL) and run\nthe correct importer\n\nUnfortunately these older files do not store as much information as Atuin does,\nso not all features are available with imported data.\n\nExcept as noted otherwise, you can set the `HISTFILE` environment variable to\ncontrol which file is read, otherwise each importer will try some default filenames.\n\n```\nHISTFILE=/path/to/history/file atuin import zsh\n```\n\nNote that for shells such as Xonsh that store history in many files rather than a\nsingle file, `$HISTFILE` should be set to the directory in which those files reside.\n\nFor formats that don't store timestamps, timestamps will be generated starting at\nthe current time plus 1ms for each additional command in the history.\n\nMost importers will discard commands found that have invalid UTF-8.\n\n## bash\n\nThis will read the history from `$HISTFILE` or `$HOME/.bash_history`.\n\nWarnings will be issued if timestamps are found to be out of order, which could also\nhappen when a history file starts without timestamps but later entries include them.\n\n## fish\n\nfish supports multiple history sessions, so the importer will default to the `fish`\nsession unless the `fish_history` environment variable is set. The file to be read\nwill be `{session}_history` in `$XDG_DATA_HOME/fish/` (or `$HOME/.local/share/fish`).\n\nNot all of the data in the fish history is preserved, some data about filenames used\nfor each command are not used by Atuin, so it is discarded.\n\n## nu\n\nThis importer reads from Nushell's text history format, which is stored in\n`$XDG_CONFIG_HOME/nushell/history.txt` or `$HOME/.config/nushell/history.txt`.\nThere is no way to set the filename otherwise.\n\n## nu-hist-db\n\nThis importer reads from Nushell's SQLite history database, which is stored in\n`$XDG_CONFIG_HOME/nushell/history.sqlite3` or `$HOME/.config/nushell/history.sqlite3`.\nThere is no way to set the filename otherwise.\n\n## powershell\n\nThis importer reads from\n[PowerShell's history file](https://learn.microsoft.com/en-us/powershell/module/psreadline/about/about_psreadline#command-history).\nOn Windows, the file is located at\n`$APPDATA\\Microsoft\\Windows\\PowerShell\\PSReadLine\\ConsoleHost_history.txt`.\nOn other systems, it is located at\n`$XDG_DATA_HOME/powershell/PSReadLine/ConsoleHost_history.txt`\nor `$HOME/.local/share/powershell/PSReadLine/ConsoleHost_history.txt`.\n\n## replxx\n\nThe [replxx](https://github.com/AmokHuginnsson/replxx) importer will read from\n`$HISTFILE` or `$HOME/.histfile`.\n\n## resh\n\nThe [RESH](https://github.com/curusarn/resh) importer will read from `$HISTFILE`\nor `$HOME/.resh_history.json`.\n\n## xonsh\n\nThe Xonsh importer will read from all JSON files it finds in the Xonsh history\ndirectory. The location of the directory is determined as follows:\n* If `$HISTFILE` is set, its value is used as the history directory.\n* If `$XONSH_DATA_DIR` is set (as it typically will be if the importer is invoked\nfrom within Xonsh), `$XONSH_DATA_DIR/history_json` is used.\n* If `$XDG_DATA_HOME` is set, `$XDG_DATA_HOME/xonsh/history_json` is used.\n* Otherwise, `$HOME/.local/share/xonsh/history_json` is used.\n\nNot all data present in Xonsh history JSON files is used by Atuin. Xonsh stores the\nenvironment variables present when each session was initiated, but this data is\ndiscarded by Atuin. Xonsh optionally stores the output of each command; if present\nthis data is also ignored by Atuin.\n\n## xonsh-sqlite\n\nThe Xonsh SQLite importer will read from the Xonsh SQLite history file. The history\nfile's location is determined by the same process as the regular Xonsh importer,\nbut with `history_json` replaced by `xonsh-history.sqlite`.\n\nThe Xonsh SQLite backend does not store environment variables, but like the JSON\nbackend it can optionally store the output of each command. As with the JSON backend,\nif present this data will be ignored by Atuin.\n\n## zsh\n\nThis will read the Zsh history from `$HISTFILE` or `$HOME/.zhistory`\nor `$HOME/.zsh_history` in either the simple or extended format.\n\n## zsh-hist-db\n\nThis will read the Zsh histdb SQLite file from `$HISTDB_FILE` or\n`$HOME/.histdb/zsh-history.db`.\n"
  },
  {
    "path": "docs/docs/reference/info.md",
    "content": "# info\n\n## `atuin info`\n\nThis command shows the location of config files on your system\n\nExample output:\n\n```\nConfig files:\nclient config: \"/Users/ellie/.config/atuin/config.toml\"\nserver config: \"/Users/ellie/.config/atuin/server.toml\"\nclient db path: \"/Users/ellie/.local/share/atuin/history.db\"\nkey path: \"/Users/ellie/.local/share/atuin/key\"\nsession path: \"/Users/ellie/.local/share/atuin/session\"\n\nEnv Vars:\nATUIN_CONFIG_DIR = \"None\"\n\nVersion info:\nversion: 18.1.0\n```\n"
  },
  {
    "path": "docs/docs/reference/list.md",
    "content": "# history list\n\n## `atuin history list`\n\n\n| Arg              | Description                                                                   |\n|------------------|-------------------------------------------------------------------------------|\n| `--cwd`/`-c`     | List history for the current directory only (default: all dirs)               |\n| `--session`/`-s` | List history for the current session only (default: false)                    |\n| `--human`        | Use human-readable formatting for the timestamp and duration (default: false) |\n| `--cmd-only`     | Show only the text of the command (default: false)                            |\n| `--reverse`      | Reverse the order of the output (default: false)                              |\n| `--format`       | Specify the formatting of a command (see below)                               |\n| `--print0`       | Terminate the output with a null, for better multiline support                                                                              |\n\n\n## Format\n\nCustomize the output of `history list`\n\nExample\n\n```\natuin history list --format \"{time} - {duration} - {command}\"\n```\n\nSupported variables\n\n```\n{command}, {directory}, {duration}, {user}, {host} and {time}\n```\n"
  },
  {
    "path": "docs/docs/reference/prune.md",
    "content": "# history prune\n\n## `atuin history prune`\n\nThis command deletes history entries matching the [history_filter](../configuration/config.md#history_filter) configuration parameter.\n\nThese may be commands that match the current `history_filter` configuration, but were saved to history before the filter was set up, and therefore were not excluded from history at the time of execution.\n\nIt may be useful to run the prune command after updating `history_filter` to remove old history entries that match the new filters.\n\nIt can be run with `--dry-run` first to list history entries that will be removed.\n\n| Argument         | Description                                                        |\n|------------------|--------------------------------------------------------------------|\n| `--dry-run`/`-n` | List matching history lines without performing the actual deletion |\n"
  },
  {
    "path": "docs/docs/reference/search.md",
    "content": "# search\n\nAtuin search supports wildcards, with either the `*` or `%` character. By\ndefault, a prefix search is performed (ie, all queries are automatically\nappended with a wildcard).\n\n| Arg &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;| Description |\n| -------------------- | ----------------------------------------------------------------------------- |\n| `--cwd`/`-c`         | The directory to list history for (default: all dirs)                         |\n| `--exclude-cwd`      | Do not include commands that ran in this directory (default: none)            |\n| `--exit`/`-e`        | Filter by exit code (default: none)                                           |\n| `--exclude-exit`     | Do not include commands that exited with this value (default: none)           |\n| `--before`           | Only include commands ran before this time(default: none)                     |\n| `--after`            | Only include commands ran after this time(default: none)                      |\n| `--interactive`/`-i` | Open the interactive search UI (default: false)                               |\n| `--human`            | Use human-readable formatting for the timestamp and duration (default: false) |\n| `--limit`            | Limit the number of results (default: none)                                   |\n| `--offset`           | Offset from the start of the results (default: none)                          |\n| `--delete`           | Delete history matching this query                                            |\n| `--delete-it-all`    | Delete all shell history                                                      |\n| `--reverse`          | Reverse order of search results, oldest first                                 |\n| `--format`/`-f`      | Available variables: {command}, {directory}, {duration}, {user}, {host}, {time}, {exit} and {relativetime}. Example: --format \"{time} - [{duration}] - {directory}$\\t{command}\" |\n| `--inline-height`    | Set the maximum number of lines Atuin's interface should take up              |\n| `--help`/`-h`        | Print help                                                                    |\n\n## `atuin search -i`\n\nAtuin's interactive search TUI allows you to fuzzy search through your history.\n\n![compact](https://user-images.githubusercontent.com/1710904/161623659-4fec047f-ea4b-471c-9581-861d2eb701a9.png)\n\nYou can replay the `nth` command with `alt + #` where `#` is the line number of the command you would like to replay.\n\nNote: This is not yet supported on macOS.\n\n## Examples\n\n```\n# Open the interactive search TUI\natuin search -i\n\n# Open the interactive search TUI preloaded with a query\natuin search -i atuin\n\n# Search for all commands, beginning with cargo, that exited successfully\natuin search --exit 0 cargo\n\n# Search for all commands, that failed, from the current dir, and were ran before April 1st 2021\natuin search --exclude-exit 0 --before 01/04/2021 --cwd .\n\n# Search for all commands, beginning with cargo, that exited successfully, and were ran after yesterday at 3pm\natuin search --exit 0 --after \"yesterday 3pm\" cargo\n\n# Delete all commands, beginning with cargo, that exited successfully, and were ran after yesterday at 3pm\natuin search --delete --exit 0 --after \"yesterday 3pm\" cargo\n\n# Search for a command beginning with cargo, return exactly one result.\natuin search --limit 1 cargo\n\n# Search for a single result for a command beginning with cargo, skipping (offsetting) one result\natuin search --offset 1 --limit 1 cargo\n\n# Find the oldest cargo command\natuin search --limit 1 --reverse cargo\n```\n"
  },
  {
    "path": "docs/docs/reference/stats.md",
    "content": "# stats\n\nAtuin can also calculate stats based on your history - this is currently a\nlittle basic, but more features to come.\n\n## 1-day stats\n\nYou provide the starting point, and Atuin computes the stats for 24h from that point.\nDate parsing is provided by `interim`, which supports different formats\nfor full or relative dates. Certain formats rely on the dialect option in your\n[configuration](../configuration/config.md#dialect) to differentiate day from month.\nRefer to [the module's documentation](https://docs.rs/interim/0.1.0/interim/#supported-formats) for more details on the supported date formats.\n\n```\n$ atuin stats last friday\n\n+---------------------+------------+\n| Statistic           | Value      |\n+---------------------+------------+\n| Most used command   | git status |\n+---------------------+------------+\n| Commands ran        |        450 |\n+---------------------+------------+\n| Unique commands ran |        213 |\n+---------------------+------------+\n\n# A few more examples:\n$ atuin stats 2018-04-01\n$ atuin stats April 1\n$ atuin stats 01/04/22\n$ atuin stats last thursday 3pm  # between last thursday 3:00pm and the following friday 3:00pm\n```\n\n## Full history stats\n\n```\n$ atuin stats\n# or\n$ atuin stats all\n\n+---------------------+-------+\n| Statistic           | Value |\n+---------------------+-------+\n| Most used command   |    ls |\n+---------------------+-------+\n| Commands ran        |  8190 |\n+---------------------+-------+\n| Unique commands ran |  2996 |\n+---------------------+-------+\n```\n"
  },
  {
    "path": "docs/docs/reference/sync.md",
    "content": "# sync\n\nAtuin can back up your history to a server, and use this to ensure multiple\nmachines have the same shell history. This is all encrypted end-to-end, so the\nserver operator can _never_ see your data!\n\nAnyone can host a server (try `atuin server start`, more docs to follow), but I\nhost one at https://api.atuin.sh. This is the default server address, which can\nbe changed in the [config](../configuration/config.md#sync_address). Again, I _cannot_ see your data, and\ndo not want to.\n\n## Sync frequency\n\nSyncing will happen automatically, unless configured otherwise. The sync\nfrequency is configurable in [config](../configuration/config.md#sync_frequency)\n\n## Sync\n\nYou can manually trigger a sync with `atuin sync`\n\n## Register\n\nRegister for a sync account with\n\n```\natuin register -u <USERNAME> -e <EMAIL> -p <PASSWORD>\n```\n\nIf you don't want to have your password be included in shell history, you can omit\nthe password flag and you will be prompted to provide it through stdin.\n\nUsernames must be unique and only contain alphanumerics or hyphens,\nand emails shall only be used for important notifications (security breaches, changes to service, etc).\n\nUpon success, you are also logged in :) Syncing should happen automatically from\nhere!\n\n## Delete\n\nYou can delete your sync account with\n\n```\natuin account delete\n```\n\nThis will remove your account and all synchronized history from the server. Local data will not be touched!\n\n## Key\n\nAs all your data is encrypted, Atuin generates a key for you. It's stored in the\nAtuin data directory (`~/.local/share/atuin` on Linux).\n\nYou can also get this with\n\n```\natuin key\n```\n\nNever share this with anyone!\n\n## Login\n\nIf you want to log in to a new machine, you will require your encryption key\n(`atuin key`).\n\n```\natuin login -u <USERNAME> -p <PASSWORD> -k <KEY>\n```\n\nIf you don't want to have your password or encryption key be included in shell history, you can omit\nthe corresponding flag and you will be prompted to provide it through stdin.\n\n## Logout\n\n```\natuin logout\n```\n"
  },
  {
    "path": "docs/docs/self-hosting/docker.md",
    "content": "# Docker\n\n!!! warning\n    If you are self hosting, we strongly suggest you stick to [tagged releases](https://github.com/atuinsh/atuin/releases), and do not follow `main` or `latest`\n\n    Follow the GitHub releases, and please read the notes for each release. Most of the time, upgrades can occur without any manual intervention.\n\n    We cannot guarantee that all updates will apply cleanly, and some may require some extra steps.\n\nThere is a supplied docker image to make deploying a server as a container easier. The \"LATEST TAGGED RELEASE\" can be found on the [releases page](https://github.com/atuinsh/atuin/releases).\n\n```sh\nCONFIG=\"$HOME/.config/atuin\"\nmkdir \"$CONFIG\"\nchown 1000:1000 \"$CONFIG\"\ndocker run -d -v \"$CONFIG:/config\" ghcr.io/atuinsh/atuin:<LATEST TAGGED RELEASE> start\n```\n\n## Docker Compose\n\nUsing the already build docker image hosting your own Atuin can be done using the supplied docker-compose file.\n\nCreate a `.env` file next to [`docker-compose.yml`](https://github.com/atuinsh/atuin/raw/main/docker-compose.yml) with contents like this:\n\n```ini\nATUIN_DB_NAME=atuin\nATUIN_DB_USERNAME=atuin\n# Choose your own secure password. Stick to [A-Za-z0-9.~_-]\nATUIN_DB_PASSWORD=really-insecure\n```\n\nCreate a `docker-compose.yml`:\n\n```yaml\nservices:\n  atuin:\n    restart: always\n    image: ghcr.io/atuinsh/atuin:<LATEST TAGGED RELEASE>\n    command: start\n    volumes:\n      - \"./config:/config\"\n    ports:\n      - 8888:8888\n    environment:\n      ATUIN_HOST: \"0.0.0.0\"\n      ATUIN_OPEN_REGISTRATION: \"true\"\n      ATUIN_DB_URI: postgres://${ATUIN_DB_USERNAME}:${ATUIN_DB_PASSWORD}@db/${ATUIN_DB_NAME}\n      RUST_LOG: info,atuin_server=debug\n    depends_on:\n      - db\n  db:\n    image: postgres:18\n    restart: unless-stopped\n    volumes: # Don't remove permanent storage for index database files!\n      - \"./database:/var/lib/postgresql/\"\n    environment:\n      POSTGRES_USER: ${ATUIN_DB_USERNAME}\n      POSTGRES_PASSWORD: ${ATUIN_DB_PASSWORD}\n      POSTGRES_DB: ${ATUIN_DB_NAME}\n```\n\nStart the services using `docker compose`:\n\n```sh\nmkdir config\nchown 1000:1000 config\ndocker compose up -d\n```\n\n## Using systemd to manage your atuin server\n\nThe following `systemd` unit file to manage your `docker-compose` managed service:\n\n```ini\n[Unit]\nDescription=Docker Compose Atuin Service\nRequires=docker.service\nAfter=docker.service\n\n[Service]\n# Where the docker-compose file is located\nWorkingDirectory=/srv/atuin-server\nExecStart=/usr/bin/docker compose up\nExecStop=/usr/bin/docker compose down\nTimeoutStartSec=0\nRestart=on-failure\nStartLimitBurst=3\n\n[Install]\nWantedBy=multi-user.target\n```\n\nStart and enable the service with:\n\n```sh\nsystemctl enable --now atuin\n```\n\nCheck if its running with:\n\n```sh\nsystemctl status atuin\n```\n\n## Creating backups of the Postgres database\n\nYou can add another service to your `docker-compose.yml` file to have it run daily backups. It should look like this:\n\n```yaml\n  backup:\n    container_name: atuin_db_dumper\n    image: prodrigestivill/postgres-backup-local\n    env_file:\n      - .env\n    environment:\n      POSTGRES_HOST: postgresql\n      POSTGRES_DB: ${ATUIN_DB_NAME}\n      POSTGRES_USER: ${ATUIN_DB_USERNAME}\n      POSTGRES_PASSWORD: ${ATUIN_DB_PASSWORD}\n      SCHEDULE: \"@daily\"\n      BACKUP_DIR: /db_dumps\n    volumes:\n      - ./db_dumps:/db_dumps\n    depends_on:\n      - postgresql\n```\n\nThis will create daily backups of your database for that additional layer of comfort.\n\nPLEASE NOTE: The `./db_dumps` mount MUST be a POSIX-compliant filesystem to store the backups (mainly with support for hardlinks and softlinks). So filesystems like VFAT, EXFAT, SMB/CIFS, ... can't be used with this docker image. See https://github.com/prodrigestivill/docker-postgres-backup-local for more details on how this works. There are additional settings for the number of backups retained, etc., all explained on the linked page.\n"
  },
  {
    "path": "docs/docs/self-hosting/kubernetes.md",
    "content": "# Kubernetes\n\n!!! warning\n    If you are self hosting, we strongly suggest you stick to tagged releases, and do not follow `main` or `latest`\n\n    Follow the GitHub releases, and please read the notes for each release. Most of the time, upgrades can occur without any manual intervention.\n\n    We cannot guarantee that all updates will apply cleanly, and some may require some extra steps.\n\nYou could host your own Atuin server using the Kubernetes platform.\n\nCreate a [`secrets.yaml`](https://github.com/atuinsh/atuin/blob/main/k8s/secrets.yaml) file for the database credentials:\n\n```yaml\napiVersion: v1\nkind: Secret\nmetadata:\n  name: atuin-secrets\ntype: Opaque\nstringData:\n  ATUIN_DB_USERNAME: atuin\n  ATUIN_DB_PASSWORD: seriously-insecure\n  ATUIN_HOST: \"127.0.0.1\"\n  ATUIN_PORT: \"8888\"\n  ATUIN_OPEN_REGISTRATION: \"true\"\n  ATUIN_DB_URI: \"postgres://atuin:seriously-insecure@postgres/atuin\"\nimmutable: true\n```\n\nCreate a [`atuin.yaml`](https://github.com/atuinsh/atuin/blob/main/k8s/atuin.yaml) file for the Atuin server:\n\n```yaml\n---\napiVersion: apps/v1\nkind: Deployment\nmetadata:\n  name: postgres\n  namespace: atuin\nspec:\n  replicas: 1\n  strategy:\n    type: Recreate # This is important to ensure duplicate pods don't run and cause corruption\n  selector:\n    matchLabels:\n      io.kompose.service: postgres\n  template:\n    metadata:\n      labels:\n        io.kompose.service: postgres\n    spec:\n      containers:\n        - name: postgresql\n          image: postgres:14\n          ports:\n            - containerPort: 5432\n          env:\n            - name: POSTGRES_DB\n              value: atuin\n            - name: POSTGRES_PASSWORD\n              valueFrom:\n                secretKeyRef:\n                  name: atuin-secrets\n                  key: ATUIN_DB_PASSWORD\n                  optional: false\n            - name: POSTGRES_USER\n              valueFrom:\n                secretKeyRef:\n                  name: atuin-secrets\n                  key: ATUIN_DB_USERNAME\n                  optional: false\n          lifecycle:\n            preStop:\n              exec:\n                # This ensures graceful shutdown see: https://stackoverflow.com/a/75829325/3437018\n                # Potentially consider using a `StatefulSet` instead of a `Deployment`\n                command: [\"/usr/local/bin/pg_ctl stop -D /var/lib/postgresql/data -w -t 60 -m fast\"]\n          resources:\n            requests:\n              cpu: 100m\n              memory: 100Mi\n            limits:\n              cpu: 250m\n              memory: 600Mi\n          volumeMounts:\n            - mountPath: /var/lib/postgresql/data/\n              name: database\n      volumes:\n        - name: database\n          persistentVolumeClaim:\n            claimName: database\n---\napiVersion: apps/v1\nkind: Deployment\nmetadata:\n  name: atuin\nspec:\n  replicas: 1\n  selector:\n    matchLabels:\n      io.kompose.service: atuin\n  template:\n    metadata:\n      labels:\n        io.kompose.service: atuin\n    spec:\n      containers:\n        - args:\n            - server\n            - start\n          env:\n            - name: ATUIN_DB_URI\n              valueFrom:\n                secretKeyRef:\n                  name: atuin-secrets\n                  key: ATUIN_DB_URI\n                  optional: false\n            - name: ATUIN_HOST\n              value: 0.0.0.0\n            - name: ATUIN_PORT\n              value: \"8888\"\n            - name: ATUIN_OPEN_REGISTRATION\n              value: \"true\"\n          image: ghcr.io/atuinsh/atuin:latest\n          name: atuin\n          ports:\n            - containerPort: 8888\n          resources:\n            limits:\n              cpu: 250m\n              memory: 1Gi\n            requests:\n              cpu: 250m\n              memory: 1Gi\n          volumeMounts:\n            - mountPath: /config\n              name: atuin-claim0\n      volumes:\n        - name: atuin-claim0\n          persistentVolumeClaim:\n            claimName: atuin-claim0\n---\napiVersion: v1\nkind: Service\nmetadata:\n  labels:\n    io.kompose.service: atuin\n  name: atuin\nspec:\n  type: NodePort\n  ports:\n    - name: \"8888\"\n      port: 8888\n      nodePort: 30530\n  selector:\n    io.kompose.service: atuin\n---\napiVersion: v1\nkind: Service\nmetadata:\n  labels:\n    io.kompose.service: postgres\n  name: postgres\nspec:\n  type: ClusterIP\n  selector:\n    io.kompose.service: postgres\n  ports:\n    - protocol: TCP\n      port: 5432\n      targetPort: 5432\n---\nkind: PersistentVolume\napiVersion: v1\nmetadata:\n  name: database-pv\n  labels:\n    app: database\n    type: local\nspec:\n  storageClassName: manual\n  capacity:\n    storage: 300Mi\n  accessModes:\n    - ReadWriteOnce\n  hostPath:\n    path: \"/Users/firstname.lastname/.kube/database\"\n---\napiVersion: v1\nkind: PersistentVolumeClaim\nmetadata:\n  labels:\n    io.kompose.service: database\n  name: database\nspec:\n  storageClassName: manual\n  accessModes:\n    - ReadWriteOnce\n  resources:\n    requests:\n      storage: 300Mi\n---\napiVersion: v1\nkind: PersistentVolumeClaim\nmetadata:\n  labels:\n    io.kompose.service: atuin-claim0\n  name: atuin-claim0\nspec:\n  accessModes:\n    - ReadWriteOnce\n  resources:\n    requests:\n      storage: 10Mi\n```\n\nFinally, you may want to use a separate namespace for atuin, by creating a [`namespaces.yaml`](https://github.com/atuinsh/atuin/blob/main/k8s/namespaces.yaml) file:\n\n```yaml\napiVersion: v1\nkind: Namespace\nmetadata:\n  name: atuin-namespace\n  labels:\n    name: atuin\n```\n\nNote that this configuration will store the database folder _outside_ the kubernetes cluster, in the folder `/Users/firstname.lastname/.kube/database` of the host system by configuring the `storageClassName` to be `manual`. In a real enterprise setup, you would probably want to store the database content permanently in the cluster, and not in the host system.\n\nYou should also change the password string in `ATUIN_DB_PASSWORD` and `ATUIN_DB_URI` in the`secrets.yaml` file to a more secure one.\n\nThe atuin service on the port `30530` of the host system. That is configured by the `nodePort` property. Kubernetes has a strict rule that you are not allowed to expose a port numbered lower than 30000. To make the clients work, you can simply set the port in in your `config.toml` file, e.g. `sync_address = \"http://192.168.1.10:30530\"`.\n\nDeploy the Atuin server using `kubectl`:\n\n```shell\n  kubectl apply -f ./namespaces.yaml\n  kubectl apply -n atuin-namespace \\\n                -f ./secrets.yaml \\\n                -f ./atuin.yaml\n```\n\nThe sample files above are also in the [k8s folder](https://github.com/atuinsh/atuin/tree/main/k8s) of the atuin repository.\n\n## Creating backups of the Postgres database\n\nNow you're up and running it's a good time to think about backups.\n\nYou can create a [`CronJob`](https://kubernetes.io/docs/concepts/workloads/controllers/cron-jobs/) which uses [`pg_dump`](https://www.postgresql.org/docs/current/app-pgdump.html) to create a backup of the database. This example runs weekly and dumps to the local disk on the node.\n\n```yaml\napiVersion: batch/v1\nkind: CronJob\nmetadata:\n  name: atuin-db-backup\nspec:\n  schedule: \"0 0 * * 0\" # Run every Sunday at midnight\n  jobTemplate:\n    spec:\n      template:\n        spec:\n          containers:\n          - name: atuin-db-backup-pg-dump\n            image: postgres:14\n            command: [\n              \"/bin/bash\",\n              \"-c\",\n              \"pg_dump --host=postgres --username=atuin --format=c --file=/backup/atuin-backup-$(date +'%Y-%m-%d').pg_dump\",\n            ]\n            env:\n              - name: PGPASSWORD\n                valueFrom:\n                  secretKeyRef:\n                    name: atuin-secrets\n                    key: ATUIN_DB_PASSWORD\n                    optional: false\n            volumeMounts:\n            - name: backup-volume\n              mountPath: /backup\n          restartPolicy: OnFailure\n          volumes:\n          - name: backup-volume\n            hostPath:\n              path: /somewhere/on/node/for/database-backups\n              type: Directory\n```\n\nConfigure/update the example `yaml` with the following:\n- Set a more or less frequent schedule with the `schedule` property.\n- Replace `/somewhere/on/node/for/database-backups` with a path on your node or reconfigure to use a `PersistentVolume` instead of `hostPath`.\n- `--format=c` outputs a format that can be restored with `pg_restore`. Use [`plain`](https://www.postgresql.org/docs/current/app-pgdump.html) if you want `.sql` files outputted instead.\n"
  },
  {
    "path": "docs/docs/self-hosting/server-setup.md",
    "content": "# Server setup\n\nWhile we offer a public sync server, and cannot view your data (as it is encrypted), you may still wish to self host your own Atuin sync server.\n\nThe requirements to do so are pretty minimal! You need to be able to run a binary or docker container, and have a PostgreSQL database setup. Atuin requires PostgreSQL 14 or above.\n\nAtuin also supports sqlite 3 and above.\n\nAny host with the `atuin` binary may also run a server, by running\n\n```shell\natuin server start\n```\n\n## Configuration\n\nThe config for the server is kept separate from the config for the client, even\nthough they are the same binary. Server config can be found at\n`~/.config/atuin/server.toml`.\n\nIt looks something like this for PostgreSQL:\n\n```toml\nhost = \"0.0.0.0\"\nport = 8888\nopen_registration = true\ndb_uri=\"postgres://user:password@hostname/database\"\n```\n\nAlternatively, configuration can also be provided with environment variables.\n\n```sh\nATUIN_HOST=\"0.0.0.0\"\nATUIN_PORT=8888\nATUIN_OPEN_REGISTRATION=true\nATUIN_DB_URI=\"postgres://user:password@hostname/database\"\n```\n\n| Parameter           | Description                                                    |\n| ------------------- | -------------------------------------------------------------- |\n| `host`              | The host to listen on (default: 127.0.0.1)                     |\n| `port`              | The TCP port to listen on (default: 8888)                      |\n| `open_registration` | If `true`, accept new user registrations (default: false)      |\n| `db_uri`            | A valid PostgreSQL URI, for saving history (default: false)    |\n| `path`              | A path to prepend to all routes of the server (default: false) |\n\nFor sqlite, use the following in your server.toml:\n\n```toml\ndb_uri=\"sqlite:///config/atuin.db\"\n```\n\nAlternatively, provide the Database URI via an environment variable\n\n```sh\nATUIN_DB_URI=\"sqlite:///config/atuin.db\"\n```\n\nThese will create the database in the `/config` directory. Be sure to map a persistent volume to the `/config` directory that is writable by the atuin server.\n\n### TLS\n\nFor TLS/HTTPS support, we recommend using a reverse proxy such as nginx, caddy, or traefik in front of the Atuin server. This is the standard approach for containerized applications and provides better flexibility for certificate management.\n\n> **Note:** The built-in `[tls]` configuration option has been removed. If you were previously using it, please migrate to a reverse proxy setup. Any existing `[tls]` sections in your config will be ignored.\n"
  },
  {
    "path": "docs/docs/self-hosting/systemd.md",
    "content": "# Systemd\n\nFirst, create the service unit file\n[`atuin-server.service`](https://github.com/atuinsh/atuin/raw/main/systemd/atuin-server.service) at\n`/etc/systemd/system/atuin-server.service` with contents like this:\n\n```ini\n[Unit]\nDescription=Start the Atuin server syncing service\nAfter=network-online.target\nWants=network-online.target systemd-networkd-wait-online.service\n\n[Service]\nExecStart=atuin server start\nRestart=on-failure\nUser=atuin\nGroup=atuin\n\nEnvironment=ATUIN_CONFIG_DIR=/etc/atuin\nReadWritePaths=/etc/atuin\n\n# Hardening options\nCapabilityBoundingSet=\nAmbientCapabilities=\nNoNewPrivileges=true\nProtectHome=true\nProtectSystem=strict\nProtectKernelTunables=true\nProtectKernelModules=true\nProtectControlGroups=true\nPrivateTmp=true\nPrivateDevices=true\nLockPersonality=true\n\n[Install]\nWantedBy=multi-user.target\n```\n\nThis is the official Atuin service unit file which includes a lot of hardening options to increase\nsecurity.\n\nNext, create [`atuin-server.conf`](https://github.com/atuinsh/atuin/raw/main/systemd/atuin-server.sysusers) at\n`/etc/sysusers.d/atuin-server.conf` with contents like this:\n\n```\nu atuin - \"Atuin synchronized shell history\"\n```\nThis file will ensure a system user is created in the proper manner.\n\nAfterwards, run\n```sh\nsystemctl restart systemd-sysusers\n```\nto make sure the file is read. A new `atuin-server` user should then be available.\n\nNow, you can attempt to run the Atuin server:\n```sh\nsystemctl enable --now atuin-server\n```\n\n```sh\nsystemctl status atuin-server\n```\n\nIf it started fine, it should have created the default config inside `/etc/atuin/`.\n"
  },
  {
    "path": "docs/docs/self-hosting/usage.md",
    "content": "# Using a self hosted server\n\n!!! warning\n    If you are self hosting, we strongly suggest you stick to tagged releases, and do not follow `main` or `latest`\n\n    Follow the GitHub releases, and please read the notes for each release. Most of the time, upgrades can occur without any manual intervention.\n\n    We cannot guarantee that all updates will apply cleanly, and some may require some extra steps.\n\n## Client setup\n\nIn order use a self hosted server with Atuin, you'll have to set up the `sync_address` in the config file at `~/.config/atuin/config.toml`. See the [config](../configuration/config.md#sync_address) page for more details on how to set the `sync_address`.\n\nAlternatively you can set the environment variable `ATUIN_SYNC_ADDRESS` to the correct host ie.: `ATUIN_SYNC_ADDRESS=https://api.atuin.sh`.\n"
  },
  {
    "path": "docs/docs/sync-v2.md",
    "content": "# Sync v2\n\nJust installed Atuin? Don't worry about this page, everything should be set up\nfor you.\n\nIf you've been using Atuin for a while, you might not be using the best sync. A\nlong time ago, we shipped sync v1. It was \"good enough\", but never intended for\nthe wide use it ended up getting.\n\nAfter evaluating issues and feedback from users, we developed sync v2. It\nincludes a whole bunch of changes that I'll save writing about for a future\nblog post or deep dive, but suffice to say it's\n\n1. Faster\n2. More reliable\n3. Uses less bandwidth\n4. Supports many more features\n5. Recovers from issues more effectively\n\nThere's really no reason to not use it.\n\nYou can opt in with the following configuration\n\n```toml\n[sync]\nrecords = true\n```\n"
  },
  {
    "path": "docs/docs/uninstall.md",
    "content": "# Uninstalling Atuin\n\nSorry to see you go!\n\nIf you used the Atuin installer, you can totally delete it by removing the following\n\n1. Delete the ~/.atuin directory\n2. Delete the ~/.config/atuin directory\n3. Delete the ~/.local/share/atuin directory\n4. Remove the line referencing \"atuin init\" from your shell config\n\nOtherwise, uninstalling Atuin depends on your system, and how you installed it.\n\nEG, on macOS, you'd want to run\n\n```\nbrew uninstall atuin\n```\n\nand then remove the shell integration.\n"
  },
  {
    "path": "docs/mkdocs.yml",
    "content": "site_name: Atuin CLI Docs\nsite_url: https://docs.atuin.sh/cli/\n\nrepo_name: atuinsh/atuin\nrepo_url: https://github.com/atuinsh/atuin\nedit_uri: edit/main/docs/docs/\n\ntheme:\n  name: material\n  palette:\n    - scheme: default\n      primary: deep purple\n      accent: deep purple\n      toggle:\n        icon: material/brightness-7\n        name: Switch to dark mode\n    - scheme: slate\n      primary: deep purple\n      accent: deep purple\n      toggle:\n        icon: material/brightness-4\n        name: Switch to light mode\n  features:\n    - navigation.sections\n    - navigation.expand\n    - search.suggest\n    - search.highlight\n    - content.code.copy\n    - content.action.edit\n    - content.action.view\n\n# NOTE: to enable ToC and heading anchor links in local (non-multirepo) development,\n# comment out the `multirepo` plugin and all its settings.\n# Note that this changes the top-level directory when developing locally\n# from `/cli/CLI/` to `/cli/.\n\nplugins:\n  - search\n  - multirepo:\n      imported_repo: true\n      url: https://github.com/atuinsh/docs\n      section_name: CLI\n      paths: [\"mkdocs.yml\", \"docs/index.md\", \"docs/stylesheets/*\"]\n      yml_file: mkdocs.yml\n      branch: mkt/docs-migration\n\nmarkdown_extensions:\n  - pymdownx.highlight:\n      anchor_linenums: true\n  - pymdownx.superfences\n  - pymdownx.tabbed:\n      alternate_style: true\n  - admonition\n  - pymdownx.details\n  - attr_list\n  - md_in_html\n  - tables\n  - pymdownx.keys\n  - pymdownx.emoji:\n      emoji_index: !!python/name:material.extensions.emoji.twemoji\n      emoji_generator: !!python/name:material.extensions.emoji.to_svg\n\nnav:\n  - Home: index.md\n  - Guide:\n      - Getting Started: guide/getting-started.md\n      - Installation: guide/installation.md\n      - Setting up sync: guide/sync.md\n      - Import existing history: guide/import.md\n      - Basic usage: guide/basic-usage.md\n      - Advanced usage: guide/advanced-usage.md\n      - Shell Integration: guide/shell-integration.md\n      - Deleting history: guide/delete-history.md\n      - Syncing dotfiles: guide/dotfiles.md\n      - Theming: guide/theming.md\n  - Configuration:\n      - Config: configuration/config.md\n      - Key Binding: configuration/key-binding.md\n      - Advanced Key Binding: configuration/advanced-key-binding.md\n  - Reference:\n      - doctor: reference/doctor.md\n      - daemon: reference/daemon.md\n      - gen-completions: reference/gen-completions.md\n      - import: reference/import.md\n      - info: reference/info.md\n      - history list: reference/list.md\n      - history prune: reference/prune.md\n      - search: reference/search.md\n      - stats: reference/stats.md\n      - sync: reference/sync.md\n  - Self Hosting:\n      - Server Setup: self-hosting/server-setup.md\n      - Usage: self-hosting/usage.md\n      - Docker: self-hosting/docker.md\n      - Kubernetes: self-hosting/kubernetes.md\n      - Systemd: self-hosting/systemd.md\n  - AI:\n      - Introduction: ai/introduction.md\n      - Settings: ai/settings.md\n  - Known Issues: known-issues.md\n  - Integrations: integrations.md\n  - FAQ: faq.md\n  - Uninstall: uninstall.md\n  - Sync v2: sync-v2.md\n"
  },
  {
    "path": "docs/pyproject.toml",
    "content": "[project]\nname = \"atuin-cli-docs\"\nversion = \"1.0.0\"\ndescription = \"Atuin CLI documentation\"\nrequires-python = \">=3.11\"\ndependencies = [\n  \"mkdocs-material>=9.5\",\n  \"mkdocs-multirepo-plugin @ git+https://github.com/atuinsh/mkdocs-multirepo-plugin@mkt/imported_repo\",\n  \"mkdocs-git-revision-date-localized-plugin>=1.2\",\n  \"mkdocs-glightbox>=0.4\",\n  \"mkdocs-redirects>=1.2.2\",\n]\n\n[tool.uv]\noverride-dependencies = [\n    \"click==8.2.1\",\n]\n\n[dependency-groups]\ndev = []\n"
  },
  {
    "path": "docs-i18n/.gitignore",
    "content": "# Dependencies\n/node_modules\n\n# Production\n/build\n\n# Generated files\n.docusaurus\n.cache-loader\n\n# Misc\n.DS_Store\n.env.local\n.env.development.local\n.env.test.local\n.env.production.local\n\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\n"
  },
  {
    "path": "docs-i18n/ru/config_ru.md",
    "content": "# Конфигурация\n\nAutin использует два файла конфигурации. Они хранятся в `~/.config/atuin/`. Данные\nхранятся в `~/.local/share/atuin` (если не определено другое в XDG\\_\\*).\n\nПуть до катклога конфигурации может быть изменён установкой\nпараметра `ATUIN_CONFIG_DIR`. Например\n\n```\nexport ATUIN_CONFIG_DIR = /home/ellie/.atuin\n```\n\n## Пользовательская конфигурация\n\n```\n~/.config/atuin/config.toml\n```\n\nЭтот файл используется когда клиент работает на локальной машине (не сервере).\n\nSee [config.toml](../../atuin-client/config.toml) for an example\n\n### `dialect`\n\nЭтот параметр контролирует как [stats](stats.md) команда обрабатывает данные.\nМожет принимать одно из двух допустимых значений:\n\n```\ndialect = \"uk\"\n```\n\nили\n\n```\ndialect = \"us\"\n```\n\nПо умолчанию - \"us\".\n\n### `auto_sync`\n\nСинхронизироваться ли автоматически если выполнен вход. По умолчанию - да (true)\n```\nauto_sync = true/false\n```\n\n### `sync_address`\n\nАдрес сервера для синхронизации. По умолчанию `https://api.atuin.sh`.\n\n```\nsync_address = \"https://api.atuin.sh\"\n```\n\n### `sync_frequency`\n\nКак часто клиент синхронизируется с сервером. Может быть указано в\nпонятном для человека формате. Например, `10s`, `20m`, `1h`, и т.д.\nПо умолчанию `1h`\n\nЕсли стоит значение 0, Autin будет синхронизироваться после каждой выполненной команды.\nПомните, что сервера могут иметь ограничение на количество отправленных запросов.\n\n```\nsync_frequency = \"1h\"\n```\n\n### `db_path`\n\nПуть до базы данных SQlite. По умолчанию это\n`~/.local/share/atuin/history.db`.\n\n```\ndb_path = \"~/.history.db\"\n```\n\n### `key_path`\n\nПуть до ключа шифрования Autin. По умолчанию,\n`~/.local/share/atuin/key`.\n\n```\nkey = \"~/.atuin-key\"\n```\n\n### `session_path`\n\nПуть до серверного файла сессии Autin. По умолчанию,\n`~/.local/share/atuin/session`. На самом деле это просто API токен.\n\n```\nkey = \"~/.atuin-session\"\n```\n\n### `search_mode`\n\nОпределяет, какой режим поиска будет использоваться. Autin поддерживает \"prefix\",\nтекст целиком (fulltext) и неточный (\"fuzzy\") поиск. Режим \"prefix\" производит\nпоиск по \"запрос\\*\", \"fulltext\" по \"\\*запрос\\*\", и \"fuzzy\" использует\n[вот такой](#fuzzy-search-syntax) синтаксис.\n\nПо умолчанию стоит значение \"fuzzy\"\n\n### `filter_mode`\n\nФильтр, по-умолчанию использующийся для поиска\n\n| Столбец 1        | Столбец 2\t                                               |\n|------------------|----------------------------------------------------------|\n| global (default) | Искать историю команд со всех хостов, сессий и каталогов |\n| host             | Искать историю команд с этого хоста                      |\n| session          | Искать историю команд этой сессии                        |\n| directory        | Искать историю команд, выполненных в текущей папке       |\n\nРежимы поиска могут быть изменены через ctrl-r\n\n\n```\nsearch_mode = \"fulltext\"\n```\n\n#### fuzzy search syntax\n\nРежим поиска \"fuzzy\" основан на\n[fzf search syntax](https://github.com/junegunn/fzf#search-syntax).\n\n| Токен     | Тип совпадений             | Описание                            |\n|-----------|----------------------------|-------------------------------------|\n| `sbtrkt`  | fuzzy-match                | Всё, что совпадает с `sbtrkt`       |\n| `'wild`   | exact-match (В кавычках)   | Всё, что включает в себя `wild`     |\n| `^music`  | prefix-exact-match         | Всё, что начинается с `music`       |\n| `.mp3$`   | suffix-exact-match         | Всё, что заканчивается на `.mp3`    |\n| `!fire`   | inverse-exact-match        | Всё, что не включает в себя `fire`  |\n| `!^music` | inverse-prefix-exact-match | Всё, что не начинается с `music`    |\n| `!.mp3$`  | inverse-suffix-exact-match | Всё, что не заканчивается на `.mp3` |\n\nЗнак вертикальной черты означает логическое ИЛИ. Например, запрос ниже вернет\nвсё, что начинается с `core` и заканчивается либо на `go`, либо на `rb`, либо на `py`.\n\n```\n^core go$ | rb$ | py$\n```\n\n## Серверная конфигурация\n\n`// TODO`\n"
  },
  {
    "path": "docs-i18n/ru/import_ru.md",
    "content": "# `atuin import`\n\nAutin может импортировать историю из \"старого\" файла истории\n\n`atuin import auto` предпринимает попытку определить тип командного интерфейса\n(через \\$SHELL) и запускает нужный скрипт импорта.\n\nК сожалению, эти файлы содержат не так много информации, как Autin, так что не \nвсе функции будут доступны с импортированными данными.\n\n# zsh\n\n```\natuin import zsh\n```\n\nЕсли у вас есть HISTFILE, то эта команда должна сработать. Иначе, попробуйте\n\n```\nHISTFILE=/path/to/history/file atuin import zsh\n```\n\nЭтот параметр поддерживает как и упрощённый, так и полный формат.\n\n# bash\n\nTODO\n"
  },
  {
    "path": "docs-i18n/ru/key-binding_ru.md",
    "content": "# Key binding\n\nПо умолчанию, Autin будет переназначать <kbd>Ctrl-r</kbd> и клавишу 'стрелка вверх'.\nЕсли вы не хотите этого, установите параметр ATUIN_NOBIND прежде чем вызывать `atuin init`\n\nНапример,\n\n```\nexport ATUIN_NOBIND=\"true\"\neval \"$(atuin init zsh)\"\n```\n\nТаким образом вы можете разрешить переназначение клавиш Autin, если это необходимо.\nДелайте это до инициализирующего вызова.\n\n# zsh\n\nAutin устанавливает виджет ZLE \"atuin-search\"\n\n```\nexport ATUIN_NOBIND=\"true\"\neval \"$(atuin init zsh)\"\n\nbindkey '^r' atuin-search\n\n# зависит от режима терминала\nbindkey '^[[A' atuin-search\nbindkey '^[OA' atuin-search\n```\n\n# bash\n\n```\nexport ATUIN_NOBIND=\"true\"\neval \"$(atuin init bash)\"\n\n# Переопределите  ctrl-r, и любые другие сочетания горячих клавиш тут\nbind -x '\"\\C-r\": __atuin_history'\n```\n"
  },
  {
    "path": "docs-i18n/ru/list_ru.md",
    "content": "# Вывад истории на экран\n\n```\natuin history list\n```\n\n| Аргумент       | Описание                                                                       |\n| -------------- | ------------------------------------------------------------------------------ |\n| `--cwd/-c`     | Каталог, историю команд которой необходимо вывести (по умолчанию все каталоги) |\n| `--session/-s` | Выводит историю команд только текущей сессии (по умолчанию false)              |\n| `--human`      | Читаемый формат для времени и периодов времени (по умолчанию false)            |\n"
  },
  {
    "path": "docs-i18n/ru/search_ru.md",
    "content": "# `atuin search`\n\n```\natuin search <query>\n```\n\nПоиск в Atuin также поддерживает wildcards со знаками `*` или `%`.\nПо умолчанию, должен быть указан префикс (т.е. все запросы автоматически дополняются wildcard -ами)\n\n| Аргумент           | Описание                                                                                    |\n| ------------------ | ------------------------------------------------------------------------------------------- |\n| `--cwd/-c`         | Каталог, для которого отображается история (по умолчанию, все каталоги))                    |\n| `--exclude-cwd`    | Исключить команды которые запускались в этом каталоге (по умолчанию none)                   |\n| `--exit/-e`        | Фильтровать по exit code (по умолчанию none)                                                |\n| `--exclude-exit`   | Исключить команды, которые завершились с указанным значением (по умолчанию none)            |\n| `--before`         | Включить только команды, которые были запущены до указанного времени (по умолчанию none)    |\n| `--after`          | Включить только команды, которые были запущены после указанного времени (по умолчанию none) |\n| `--interactive/-i` | Открыть интерактивный поисковой графический интерфейс (по умолчанию false)                  |\n| `--human`          | Использовать читаемое формавтирование для времени и периодов времени (по умолчанию false)   |\n\n## Примеры\n\n```\n# Начать интерактивный поиск с текстовым пользовательским интерфейсом\natuin search -i\n\n# Начать интерактивный поиск с текстовым пользовательским интерфейсом и уже введённым запросом\natuin search -i atuin\n\n# Искать по всем командам, начиная с cargo, которые успешно завершились\natuin search --exit 0 cargo\n\n# Искать по всем командам которые завершились ошибками и были вызваны в текущей папке и были запущены до первого апреля 2021\natuin search --exclude-exit 0 --before 01/04/2021 --cwd .\n\n# Искать по всем командам, начиная с cargo, которые успешно завершились и были запущены после трёх часо дня вчера\natuin search --exit 0 --after \"yesterday 3pm\" cargo\n```\n"
  },
  {
    "path": "docs-i18n/ru/server_ru.md",
    "content": "# `atuin server`\n\nAutin позволяет запустить свой собственный сервер синхронизации, если вы \nне хотите использовать мой :)\n\nЗдесь есть только одна субкоманда, `atuin server start`, которая запустит \nAutin http-сервер синхронизации\n\n```\nUSAGE:\n    atuin server start [OPTIONS]\n\nFLAGS:\n        --help       Prints help information\n    -V, --version    Prints version information\n\nOPTIONS:\n    -h, --host <host>\n    -p, --port <port>\n```\n\n## config\n\nСерверная конфигурация лежит отдельно от файла пользовательсокй, даже если\nэто один и тот же бинарный файл. Серверная конфигурация лежит в `~/.config/atuin/server.toml`.\n\nЭтот файл выглядит как-то так:\n\n```toml\nhost = \"0.0.0.0\"\nport = 8888\nopen_registration = true\ndb_uri=\"postgres://user:password@hostname/database\"\n```\n\nКонфигурация так же может находииться в переменных окружения.\n\n```sh\nATUIN_HOST=\"0.0.0.0\"\nATUIN_PORT=8888\nATUIN_OPEN_REGISTRATION=true\nATUIN_DB_URI=\"postgres://user:password@hostname/database\"\n```\n\n### host\n\nАдрес хоста, который будет прослушиваться сервером Autin \n\nПо умолчанию это `127.0.0.1`.\n\n### post\n\nPOST, который будет прослушиваться сервером Autin.\n\nПо умолчанию это `8888`.\n\n### open_registration\n\nЕсли `true`, autin будет разрешать регистрацию новых пользователей.\nУстановите флаг `false`, если после создания вашего аккаута вы не хотите, чтобы другие \nмогли пользоваться вашим сервером.\n\nПо умолчанию `false`.\n\n### db_uri\n\nДействующий URI postgres, где будет сохранён аккаунт пользователя и история.\n\n## Docker\n\nПоддерживается образ Docker чтобы сделать проще развертывание сервера в контейнере.\n\n```sh\ndocker run -d -v \"$USER/.config/atuin:/config\" ghcr.io/ellie/atuin:latest server start\n```\n\n## Docker Compose\n\nИспользование вашего собственного docker-образа с хостингом вашего собственного Autin может быть реализовано через \nфайл docker-compose. \n\nСоздайте файл `.env` рядом с `docker-compode.yml` с содержанием наподобие этому:\n\n```\nATUIN_DB_USERNAME=atuin\n# Choose your own secure password\nATUIN_DB_PASSWORD=really-insecure\n```\n\nСоздайте `docker-compose.yml`:\n\n```yaml\nversion: '3.5'\nservices:\n  atuin:\n    restart: always\n    image: ghcr.io/ellie/atuin:main\n    command: server start\n    volumes:\n      - \"./config:/config\"\n    links:\n      - postgresql:db\n    ports:\n      - 8888:8888\n    environment:\n      ATUIN_HOST: \"0.0.0.0\"\n      ATUIN_OPEN_REGISTRATION: \"true\"\n      ATUIN_DB_URI: postgres://$ATUIN_DB_USERNAME:$ATUIN_DB_PASSWORD@db/atuin\n  postgresql:\n    image: postgres:14\n    restart: unless-stopped\n    volumes: # Don't remove permanent storage for index database files!\n      - \"./database:/var/lib/postgresql/data/\"\n    environment:\n      POSTGRES_USER: $ATUIN_DB_USERNAME\n      POSTGRES_PASSWORD: $ATUIN_DB_PASSWORD\n      POSTGRES_DB: atuin\n```\n\nЗапустите службы с помощью `docker-compose`:\n\n```sh\ndocker-compose up -d\n```\n\n### Использование systemd для управления сервером Autin\n\n`systemd` юнит чтобы управлять службами, контролируемыми `docker-compose`:\n\n```\n[Unit]\nDescription=Docker Compose Atuin Service\nRequires=docker.service\nAfter=docker.service\n\n[Service]\n# Where the docker-compose file is located\nWorkingDirectory=/srv/atuin-server \nExecStart=/usr/bin/docker-compose up\nExecStop=/usr/bin/docker-compose down\nTimeoutStartSec=0\nRestart=on-failure\nStartLimitBurst=3\n\n[Install]\nWantedBy=multi-user.target\n```\n\nВключите и запустите службу командой:\n\n```sh\nsystemctl enable --now atuin\n```\n\nПроверьте, работает ли:\n\n```sh\nsystemctl status atuin\n```\n\n"
  },
  {
    "path": "docs-i18n/ru/shell-completions_ru.md",
    "content": "# `atuin gen-completions`\n\n[Shell completions](https://en.wikipedia.org/wiki/Command-line_completion) для Atuin\nмогут бять сгенерированы путём указания каталога для вывода и желаемого shell через субкомманду `gen-completions`.\n\n```\n$ atuin gen-completions --shell bash --out-dir $HOME\n\nShell completion for BASH is generated in \"/home/user\"\n```\n\nВозможные команды для аргумента `--shell`могут быть следующими:\n\n- `bash`\n- `fish`\n- `zsh`\n- `powershell`\n- `elvish`\n\nТакже рекомендуем прочитать  [supported shells](./../../README.md#supported-shells).\n"
  },
  {
    "path": "docs-i18n/ru/stats_ru.md",
    "content": "# `atuin stats`\n\nAtuin также может выводить статистику, основанную на истории. Пока что в очень простом виде,\nно скоро должно появиться больше возможностей.\n\nСтатистика выводится пока только на английском\nStatistics in english only \n# TODO\n\n```\n$ atuin stats day last friday\n\n+---------------------+------------+\n| Statistic           | Value      |\n+---------------------+------------+\n| Most used command   | git status |\n+---------------------+------------+\n| Commands ran        |        450 |\n+---------------------+------------+\n| Unique commands ran |        213 |\n+---------------------+------------+\n\n$ atuin stats day 01/01/21 # also accepts absolute dates\n```\n\nТакже, может быть выведена статистика всей известной Autin истории:\n\n```\n$ atuin stats all\n\n+---------------------+-------+\n| Statistic           | Value |\n+---------------------+-------+\n| Most used command   |    ls |\n+---------------------+-------+\n| Commands ran        |  8190 |\n+---------------------+-------+\n| Unique commands ran |  2996 |\n+---------------------+-------+\n```\n"
  },
  {
    "path": "docs-i18n/ru/sync_ru.md",
    "content": "# `atuin sync`\n\nAutin может сделать резервную копию вашей истории на сервер чтобы обеспечить использование\nразными компьютерами одной и той же истории. Вся история будет зашифрована двусторонним шифрованием,\nтак что сервер _никогда_ не получит ваши данные!\n\nМожно сделать свой сервер (запустив `atuin server start`, об этом написано в других\nфайлах документациии), но у меня есть свой https://api.atuin.sh. Это серверный адрес по умолчанию,\nкоторый может быть изменён в [конфигурации](config_ru.md). Опять же, я _не_ могу получить ваши данные\nи они мне не нужны.\n\n## Частота синхронизации\n\nСинхронизация будет происходить автоматически, если обратное не было указано в конфигурации.\nОтконфигурировать сей параметр можно в [config](config_ru.md)\n\n## Синхронизация\n\nСинхронизироваться также можно вручную, используя команду `atuin sync`\n\n## Регистрация\n\nМожно зарегистрировать аккаунт для синхронизации:\n\n```\natuin register -u <USERNAME> -e <EMAIL> -p <PASSWORD>\n```\n\nИмена пользователей должны быть уникальны, и электронная почта должна использваться\nтолько для срочных уведомлений (изменения политик, нарушения безопасности и т.д.)\n\nПсоле регистрации, вы уже сразу вошли в свой аккаунт :) С этого момента синхронизация\nбудет проходить автоматически\n\n## Ключ\n\nПоскольку все данные шифруются, Autin при работе сгенерирует ваш ключ. Он будет сохранён в\nкаталоге с данными Autin (`~/.local/share/atuin` на системах с GNU/Linux)\n\nТакже можно сделать это самим:\n\n```\natuin key\n```\n\nНикогда не передавайте никому этот ключ!\n\n## Вход\n\nЕсли вы хотите войти с другого компьютера, вам потребуется ключ безопасности (`atuin key`).\n\n```\natuin login -u <USERNAME> -p <PASSWORD> -k <KEY>\n```\n\n## Выход\n\n```\natuin logout\n```\n"
  },
  {
    "path": "docs-i18n/zh-CN/README.md",
    "content": "<p align=\"center\">\n <picture>\n  <source media=\"(prefers-color-scheme: dark)\" srcset=\"https://github.com/atuinsh/atuin/assets/53315310/13216a1d-1ac0-4c99-b0eb-d88290fe0efd\">\n  <img alt=\"Text changing depending on mode. Light: 'So light!' Dark: 'So dark!'\" src=\"https://github.com/atuinsh/atuin/assets/53315310/08bc86d4-a781-4aaa-8d7e-478ae6bcd129\">\n</picture>\n</p>\n\n<p align=\"center\">\n<em>神奇的 shell 历史记录</em>\n</p>\n\n<hr/>\n\n<p align=\"center\">\n  <a href=\"https://github.com/atuinsh/atuin/actions?query=workflow%3ARust\"><img src=\"https://img.shields.io/github/actions/workflow/status/atuinsh/atuin/rust.yml?style=flat-square\" /></a>\n  <a href=\"https://crates.io/crates/atuin\"><img src=\"https://img.shields.io/crates/v/atuin.svg?style=flat-square\" /></a>\n  <a href=\"https://crates.io/crates/atuin\"><img src=\"https://img.shields.io/crates/d/atuin.svg?style=flat-square\" /></a>\n  <a href=\"https://github.com/atuinsh/atuin/blob/main/LICENSE\"><img src=\"https://img.shields.io/crates/l/atuin.svg?style=flat-square\" /></a>\n  <a href=\"https://discord.gg/Fq8bJSKPHh\"><img src=\"https://img.shields.io/discord/954121165239115808\" /></a>\n  <a rel=\"me\" href=\"https://hachyderm.io/@atuin\"><img src=\"https://img.shields.io/mastodon/follow/109944632283122560?domain=https%3A%2F%2Fhachyderm.io&style=social\"/></a>\n  <a href=\"https://twitter.com/atuinsh\"><img src=\"https://img.shields.io/twitter/follow/atuinsh?style=social\" /></a>\n</p>\n\n\n[English] | [简体中文]\n\nAtuin 使用 SQLite 数据库取代了你现有的 shell 历史，并为你的命令记录了额外的内容。此外，它还通过 Atuin 服务器，在机器之间提供可选的、完全加密的历史记录同步功能。\n\n<p align=\"center\">\n  <img src=\"../../demo.gif\" alt=\"animated\" width=\"80%\" />\n</p>\n\n<p align=\"center\">\n<em>显示退出代码、命令持续时间、上次执行时间和执行的命令</em>\n</p>\n\n除了搜索 UI，它还可以执行以下操作：\n\n```\n# 搜索昨天下午3点之后记录的所有成功的 `make` 命令\natuin search --exit 0 --after \"yesterday 3pm\" make\n```\n\n你可以使用我(ellie)托管的服务器，也可以使用你自己的服务器！或者干脆不使用 sync 功能。所有的历史记录同步都是加密，即使我想，也无法访问你的数据。且我**真的**不想。\n\n## 功能\n\n- 重新绑定 `up` 和 `ctrl-r` 的全屏历史记录搜索UI界面\n- 使用 sqlite 数据库存储 shell 历史记录\n- 备份以及同步已加密的 shell 历史记录\n- 在不同的终端、不同的会话以及不同的机器上都有相同的历史记录\n- 记录退出代码、cwd、主机名、会话、命令持续时间，等等。\n- 计算统计数据，如 \"最常用的命令\"。\n- 不替换旧的历史文件\n- 通过 <kbd>Alt-\\<num\\></kbd> 快捷键快速跳转到之前的记录\n- 通过 ctrl-r 切换过滤模式;可以仅从当前会话、目录或全局来搜索历史记录\n\n## 文档\n\n- [快速开始](#快速开始)\n- [安装](#安装)\n- [导入](./import.md)\n- [配置](./config.md)\n- [历史记录搜索](./search.md)\n- [历史记录云端同步](./sync.md)\n- [历史记录统计](./stats.md)\n- [运行你自己的服务器](./server.md)\n- [键绑定](./key-binding.md)\n- [shell 补全](./shell-completions.md)\n\n## 支持的 Shells\n\n- zsh\n- bash\n- fish\n\n## 社区\n\nAtuin 有一个 Discord 社区, 可以在 [这里](https://discord.gg/Fq8bJSKPHh) 获得\n\n# 快速开始\n\n## 使用默认的同步服务器\n\n这将为您注册由我托管的默认同步服务器。 一切都是端到端加密的，所以你的秘密是安全的！\n\n阅读下面的更多信息，了解仅供离线使用或托管您自己的服务器。\n\n```\nbash <(curl https://raw.githubusercontent.com/ellie/atuin/main/install.sh)\n\natuin register -u <USERNAME> -e <EMAIL> -p <PASSWORD>\natuin import auto\natuin sync\n```\n\n### 使用活跃图\n\n除了托管 Atuin 服务器外，还有一个服务可以用来生成你的 shell 历史记录使用活跃图！这个功能的灵感来自于 GitHub 的使用活跃图。\n\n例如，这是我的：\n\n![](https://api.atuin.sh/img/ellie.png?token=0722830c382b42777bdb652da5b71efb61d8d387)\n\n如果你也想要，请在登陆你的同步服务器后，执行\n\n```\ncurl https://api.atuin.sh/enable -d $(cat ~/.local/share/atuin/session)\n```\n\n执行结果为你的活跃图 URL 地址。可以共享或嵌入这个 URL 地址，令牌（token）并<i>不是</i>加密的，只是用来防止被枚举攻击。\n\n## 仅离线 (不同步)\n\n```\nbash <(curl https://raw.githubusercontent.com/ellie/atuin/main/install.sh)\n\natuin import auto\n```\n\n## 安装\n\n### 脚本 (推荐)\n\n安装脚本将帮助您完成设置，确保您的 shell 正确配置。 它还将使用以下方法之一，在可能的情况下首选系统包管理器（pacman、homebrew 等）。\n\n```\n# 不要以root身份运行，如果需要的话，会要求root。\nbash <(curl https://raw.githubusercontent.com/ellie/atuin/main/install.sh)\n```\n\n然后可直接看 <a href=\"#shell-plugin\">Shell 插件</a>\n\n### 通过 cargo\n\n最好使用 [rustup](https://rustup.rs/) 来设置 Rust 工具链，然后你就可以运行下面的命令:\n\n```\ncargo install atuin\n```\n\n然后可直接看 <a href=\"#shell-plugin\">Shell 插件</a>\n\n### Homebrew\n\n```\nbrew install atuin\n```\n\n然后可直接看 <a href=\"#shell-plugin\">Shell 插件</a>\n\n### MacPorts\n\nAtuin 也可以在 [MacPorts](https://ports.macports.org/port/atuin/) 中找到\n\n```\nsudo port install atuin\n```\n\n然后可直接看 <a href=\"#shell-plugin\">Shell 插件</a>\n\n### Pacman\n\nAtuin 在 Arch Linux 的 [社区存储库](https://archlinux.org/packages/community/x86_64/atuin/) 中可用。\n\n```\npacman -S atuin\n```\n\n然后可直接看 <a href=\"#shell-plugin\">Shell 插件</a>\n\n### 从源码编译安装\n\n```\ngit clone https://github.com/ellie/atuin.git\ncd atuin/crates/atuin\ncargo install --path .\n```\n\n然后可直接看 <a href=\"#shell-plugin\">Shell 插件</a>\n\n## <a id=\"shell-plugin\">Shell 插件</a>\n\n安装二进制文件后，需要安装 shell 插件。 如果你使用的是脚本安装，那么这一切应该都会帮您完成！\n\n### zsh\n\n```\necho 'eval \"$(atuin init zsh)\"' >> ~/.zshrc\n```\n\n或使用插件管理器:\n\n```\nzinit load ellie/atuin\n```\n\n### bash\n\n我们需要设置一些钩子（hooks）, 所以首先需要安装 bash-preexec :\n\n```\ncurl https://raw.githubusercontent.com/rcaloras/bash-preexec/master/bash-preexec.sh -o ~/.bash-preexec.sh\necho '[[ -f ~/.bash-preexec.sh ]] && source ~/.bash-preexec.sh' >> ~/.bashrc\n```\n\n然后设置 Atuin\n\n```\necho 'eval \"$(atuin init bash)\"' >> ~/.bashrc\n```\n\n### fish\n\n添加\n\n```\natuin init fish | source\n```\n\n到 `~/.config/fish/config.fish` 文件中的 `is-interactive` 块中\n\n### Fig\n\n通过 [Fig](https://fig.io) 可为 zsh， bash 或 fish 一键安装 `atuin` 脚本插件。\n\n<a href=\"https://fig.io/plugins/other/atuin\" target=\"_blank\"><img src=\"https://fig.io/badges/install-with-fig.svg\" /></a>\n\n## ...这个名字是什么意思?\n\nAtuin 以 \"The Great A'Tuin\" 命名, 这是一只来自 Terry Pratchett 的 Discworld 系列书籍的巨龟。\n\n[English]: ../../README.md\n[简体中文]: ./README.md\n"
  },
  {
    "path": "docs-i18n/zh-CN/config.md",
    "content": "# 配置\n\nAtuin 维护两个配置文件，存储在 `~/.config/atuin/` 中。 我们将数据存储在 `~/.local/share/atuin` 中（除非被 XDG\\_\\* 覆盖）。\n\n您可以通过设置更改配置目录的路径 `ATUIN_CONFIG_DIR`。 例如\n\n```\nexport ATUIN_CONFIG_DIR = /home/ellie/.atuin\n```\n\n## 客户端配置\n\n```\n~/.config/atuin/config.toml\n```\n\n客户端运行在用户的机器上，除非你运行的是服务器，否则这就是你所关心的。\n\n见 [config.toml](../../atuin-client/config.toml) 中的例子\n\n### `dialect`\n\n这配置了 [stats](stats.md) 命令解析日期的方式。 它有两个可能的值\n\n```\ndialect = \"uk\"\n```\n\n或者\n\n```\ndialect = \"us\"\n```\n\n默认为 \"us\".\n\n### `auto_sync`\n\n配置登录时是否自动同步。默认为 true\n\n```\nauto_sync = true/false\n```\n\n### `sync_address`\n\n同步的服务器地址！ 默认为 `https://api.atuin.sh`\n\n```\nsync_address = \"https://api.atuin.sh\"\n```\n\n### `sync_frequency`\n\n多长时间与服务器自动同步一次。这可以用一种\"人类可读\"的格式给出。例如，`10s`，`20m`，`1h`，等等。默认为 `1h` 。\n\n如果设置为 `0`，Atuin将在每个命令之后进行同步。一些服务器可能有潜在的速率限制，这不会造成任何问题。\n\n```\nsync_frequency = \"1h\"\n```\n\n### `db_path`\n\nAtuin SQlite数据库的路径。默认为 \n`~/.local/share/atuin/history.db`\n\n```\ndb_path = \"~/.history.db\"\n```\n\n### `key_path`\n\nAtuin加密密钥的路径。默认为 \n`~/.local/share/atuin/key`\n\n```\nkey = \"~/.atuin-key\"\n```\n\n### `session_path`\n\nAtuin服务器会话文件的路径。默认为 \n`~/.local/share/atuin/session` 。 这本质上只是一个API令牌\n\n```\nkey = \"~/.atuin-session\"\n```\n\n### `search_mode`\n\n使用哪种搜索模式。Atuin 支持 \"prefix\"（前缀）、\"fulltext\"（全文） 和 \"fuzzy\"（模糊）搜索模式。前缀(prefix)搜索语法为 \"query\\*\"，全文(fulltext)搜索语法为 \"\\*query\\*\"，而模糊搜索适用的搜索语法 [如下所述](#fuzzy-search-syntax) 。\n\n默认配置为 \"fuzzy\"\n\n### `filter_mode`\n\n搜索时要使用的默认过滤器\n\n| 模式   | 描述\t|\n|--------------- | --------------- |\n| global (default)   | 从所有主机、所有会话、所有目录中搜索历史记录  |\n| host   | 仅从该主机搜索历史记录   |\n| session   | 仅从当前会话中搜索历史记录   |\n| directory | 仅从当前目录搜索历史记录|\n\n过滤模式仍然可以通过 ctrl-r 来切换\n\n\n```\nsearch_mode = \"fulltext\"\n```\n\n#### `fuzzy` 的搜索语法\n\n`fuzzy` 搜索语法的基础是 [fzf 搜索语法](https://github.com/junegunn/fzf#search-syntax) 。\n\n| 内容     | 匹配类型                 | 描述                          |\n| --------- | -------------------------- | ------------------------------------ |\n| `sbtrkt`  | fuzzy-match                | 匹配 `sbtrkt` 的项目           |\n| `'wild`   | exact-match (quoted)       | 包含 `wild` 的项目            |\n| `^music`  | prefix-exact-match         | 以 `music` 开头的项目        |\n| `.mp3$`   | suffix-exact-match         | 以 `.mp3` 结尾的项目           |\n| `!fire`   | inverse-exact-match        | 不包括 `fire` 的项目     |\n| `!^music` | inverse-prefix-exact-match | 不以 `music` 开头的项目 |\n| `!.mp3$`  | inverse-suffix-exact-match | 不以 `.mp3` 结尾的项目    |\n\n\n单个条形字符术语充当 OR 运算符。 例如，以下查询匹配以 `core` 开头并以 `go`、`rb` 或 `py` 结尾的条目。\n\n```\n^core go$ | rb$ | py$\n```\n\n## 服务端配置\n\n`// TODO`\n"
  },
  {
    "path": "docs-i18n/zh-CN/docker.md",
    "content": "# Docker\n\nAtuin 提供了一个 docker 镜像（image），可以更轻松地将服务器部署为容器（container）。\n\n```sh\ndocker run -d -v \"$USER/.config/atuin:/config\" ghcr.io/ellie/atuin:latest server start\n```\n\n# Docker Compose\n\n使用已有的 docker 镜像（image）来托管你自己的 Atuin，可以使用提供的 docker-compose 文件来完成\n\n在 docker-compose.yml 同级目录下创建一个 .env 文件，内容如下:\n\n```\nATUIN_DB_USERNAME=atuin\n# 填写你的密码\nATUIN_DB_PASSWORD=really-insecure\n```\n\n创建 `docker-compose.yml` 文件：\n\n```yaml\nversion: '3.5'\nservices:\n  atuin:\n    restart: always\n    image: ghcr.io/ellie/atuin:main\n    command: server start\n    volumes:\n      - \"./config:/config\"\n    links:\n      - postgresql:db\n    ports:\n      - 8888:8888\n    environment:\n      ATUIN_HOST: \"0.0.0.0\"\n      ATUIN_OPEN_REGISTRATION: \"true\"\n      ATUIN_DB_URI: postgres://$ATUIN_DB_USERNAME:$ATUIN_DB_PASSWORD@db/atuin\n  postgresql:\n    image: postgres:14\n    restart: unless-stopped\n    volumes: # 不要删除索引数据库文件的永久存储空间!\n      - \"./database:/var/lib/postgresql/data/\"\n    environment:\n      POSTGRES_USER: $ATUIN_DB_USERNAME\n      POSTGRES_PASSWORD: $ATUIN_DB_PASSWORD\n      POSTGRES_DB: atuin\n```\n\n使用 `docker-compose` 启动服务：\n\n```sh\ndocker-compose up -d\n```\n\n## 使用 systemd 管理你的 atuin 服务器\n\n以下 `systemd` 的配置文件用来管理你的 `docker-compose` 托管服务：\n\n```\n[Unit]\nDescription=Docker Compose Atuin Service\nRequires=docker.service\nAfter=docker.service\n\n[Service]\n# Where the docker-compose file is located\nWorkingDirectory=/srv/atuin-server\nExecStart=/usr/bin/docker-compose up\nExecStop=/usr/bin/docker-compose down\nTimeoutStartSec=0\nRestart=on-failure\nStartLimitBurst=3\n\n[Install]\nWantedBy=multi-user.target\n```\n\n启用服务：\n\n```sh\nsystemctl enable --now atuin\n```\n\n检查服务是否正常运行:\n\n```sh\nsystemctl status atuin\n```\n"
  },
  {
    "path": "docs-i18n/zh-CN/import.md",
    "content": "# `atuin import`\n\nAtuin 可以从您的“旧”历史文件中导入您的历史记录\n\n`atuin import auto` 将尝试找出你的 shell（通过 \\$SHELL）并运行正确的导入器\n\n不幸的是，这些旧文件没有像 Atuin 那样存储尽可能多的信息，因此并非所有功能都可用于导入的数据。\n\n# zsh\n\n```\natuin import zsh\n```\n\n如果你设置了 HISTFILE，这应该会被选中！如果没有，可以尝试以下操作\n\n```\nHISTFILE=/path/to/history/file atuin import zsh\n```\n\n这支持简单和扩展形式\n\n# bash\n\nTODO\n"
  },
  {
    "path": "docs-i18n/zh-CN/k8s.md",
    "content": "# Kubernetes\n\n你可以使用 Kubernetes 来托管你的 Atuin 服务器。\n\n为数据库凭证创建 [`secrets.yaml`](../../k8s/secrets.yaml) 文件：\n\n```yaml\napiVersion: v1\nkind: Secret\nmetadata:\n  name: atuin-secrets\ntype: Opaque\nstringData:\n  ATUIN_DB_USERNAME: atuin\n  ATUIN_DB_PASSWORD: seriously-insecure\n  ATUIN_HOST: \"127.0.0.1\"\n  ATUIN_PORT: \"8888\"\n  ATUIN_OPEN_REGISTRATION: \"true\"\n  ATUIN_DB_URI: \"postgres://atuin:seriously-insecure@localhost/atuin\"\nimmutable: true\n```\n\n为 Atuin 服务器创建 [`atuin.yaml`](../../k8s/atuin.yaml) 文件：\n\n\n```yaml\n---\napiVersion: apps/v1\nkind: Deployment\nmetadata:\n  name: atuin\nspec:\n  replicas: 1\n  selector:\n    matchLabels:\n      io.kompose.service: atuin\n  template:\n    metadata:\n      labels:\n        io.kompose.service: atuin\n    spec:\n      containers:\n        - args:\n            - server\n            - start\n          env:\n            - name: ATUIN_DB_URI\n              valueFrom:\n                secretKeyRef:\n                  name: atuin-secrets\n                  key: ATUIN_DB_URI\n                  optional: false\n            - name: ATUIN_HOST\n              value: 0.0.0.0\n            - name: ATUIN_PORT\n              value: \"8888\"\n            - name: ATUIN_OPEN_REGISTRATION\n              value: \"true\"\n          image: ghcr.io/atuinsh/atuin:latest\n          name: atuin\n          ports:\n            - containerPort: 8888\n          resources:\n            limits:\n              cpu: 250m\n              memory: 1Gi\n            requests:\n              cpu: 250m\n              memory: 1Gi\n          volumeMounts:\n            - mountPath: /config\n              name: atuin-claim0\n        - name: postgresql\n          image: postgres:14\n          ports:\n            - containerPort: 5432\n          env:\n            - name: POSTGRES_DB\n              value: atuin\n            - name: POSTGRES_PASSWORD\n              valueFrom:\n                secretKeyRef:\n                  name: atuin-secrets\n                  key: ATUIN_DB_PASSWORD\n                  optional: false\n            - name: POSTGRES_USER\n              valueFrom:\n                secretKeyRef:\n                  name: atuin-secrets\n                  key: ATUIN_DB_USERNAME\n                  optional: false\n          resources:\n            limits:\n              cpu: 250m\n              memory: 1Gi\n            requests:\n              cpu: 250m\n              memory: 1Gi\n          volumeMounts:\n            - mountPath: /var/lib/postgresql/data/\n              name: database\n      volumes:\n        - name: database\n          persistentVolumeClaim:\n            claimName: database\n        - name: atuin-claim0\n          persistentVolumeClaim:\n            claimName: atuin-claim0\n---\napiVersion: v1\nkind: Service\nmetadata:\n  labels:\n    io.kompose.service: atuin\n  name: atuin\nspec:\n  type: NodePort\n  ports:\n    - name: \"8888\"\n      port: 8888\n      nodePort: 30530\n  selector:\n    io.kompose.service: atuin\n---\nkind: PersistentVolume\napiVersion: v1\nmetadata:\n  name: database-pv\n  labels:\n    app: database\n    type: local\nspec:\n  storageClassName: manual\n  capacity:\n    storage: 300Mi\n  accessModes:\n    - ReadWriteOnce\n  hostPath:\n    path: \"/Users/firstname.lastname/.kube/database\"\n---\napiVersion: v1\nkind: PersistentVolumeClaim\nmetadata:\n  labels:\n    io.kompose.service: database\n  name: database\nspec:\n  storageClassName: manual\n  accessModes:\n    - ReadWriteOnce\n  resources:\n    requests:\n      storage: 300Mi\n---\napiVersion: v1\nkind: PersistentVolumeClaim\nmetadata:\n  labels:\n    io.kompose.service: atuin-claim0\n  name: atuin-claim0\nspec:\n  accessModes:\n    - ReadWriteOnce\n  resources:\n    requests:\n      storage: 10Mi\n```\n\n最后，你可能想让 atuin 使用单独的命名空间（namespace），创建 [`namespace.yaml`](../../k8s/namespaces.yaml) 文件：\n\n```yaml\napiVersion: v1\nkind: Namespace\nmetadata:\n  name: atuin-namespace\n  labels:\n    name: atuin\n```\n\n在企业级安装部署时，你可能想要数据库内容永久存储在集群中，而不是在主机系统中。在上述配置中，`storageClassName` 配置为 `manual`，主机系统的挂载目录配置为 `/Users/firstname.lastname/.kube/database`，请注意，这些配置将会使得数据库内容存储在 kubernetes 集群<i>外部</i>中。\n\n你还应该将 `secrets.yaml` 文件中的 `ATUIN_DB_PASSWORD` 和 `ATUIN_DB_URI` 修改为更安全的加密字符串。\n\nAtuin 运行在主机系统的 `30530` 端口上。这是通过 `nodePort` 属性进行陪你的。Kubernetes 有一个严格规则，即不允许暴露小于 30000 的端口号。为了使客户端能够正常工作，你需要在你的 `config.toml` 文件中设置端口号，例如 `sync_address = \"http://192.168.1.10:30530\"`。\n\n使用 `kubectl` 部署 Atuin 服务器：\n\n```shell\n  kubectl apply -f ./namespaces.yaml\n  kubectl apply -n atuin-namespace \\\n                -f ./secrets.yaml \\\n                -f ./atuin.yaml\n```\n\n上面示例同时也位于 atuin 仓库（repository）的 [k8s](../../k8s) 目录下。\n"
  },
  {
    "path": "docs-i18n/zh-CN/key-binding.md",
    "content": "# 键位绑定\n\n默认情况下， Atuin 将会重新绑定 <kbd>Ctrl-r</kbd> 和 `up` 键。如果你不想使用默认绑定，请在调用 `atuin init` 之前设置 ATUIN_NOBIND\n\n例如：\n\n```\nexport ATUIN_NOBIND=\"true\"\neval \"$(atuin init zsh)\"\n```\n\n如果需要，你可以在调用 `atuin init` 之后对 Atuin 重新进行键绑定\n\n# zsh\n\nAtuin 定义了 ZLE 部件 \"atuin-search\"\n\n```\nexport ATUIN_NOBIND=\"true\"\neval \"$(atuin init zsh)\"\n\nbindkey '^r' atuin-search\n\n# 取决于终端模式\nbindkey '^[[A' atuin-search\nbindkey '^[OA' atuin-search\n```\n\n# bash\n\n```\nexport ATUIN_NOBIND=\"true\"\neval \"$(atuin init bash)\"\n\n# 绑定到 ctrl-r, 也可以在这里添加任何其他你想要的绑定方式\nbind -x '\"\\C-r\": __atuin_history'\n```\n\n# fish\n\n```\nset -gx ATUIN_NOBIND \"true\"\natuin init fish | source\n\n# 在 normal 和 insert 模式下绑定到 ctrl-r，你也可以在此处添加其他键位绑定\nbind \\cr _atuin_search\nbind -M insert \\cr _atuin_search\n```\n"
  },
  {
    "path": "docs-i18n/zh-CN/list.md",
    "content": "# 历史记录列表\n\n```\natuin history list\n```\n\n| 参数           | 描述                                                  |\n| -------------- | ----------------------------------------------------- |\n| `--cwd/-c`     | 要列出历史记录的目录（默认：所有目录）                |\n| `--session/-s` | 只对当前会话启用列表历史（默认：false）               |\n| `--human`      | 对时间戳和持续时间使用人类可读的格式（默认：false）。 |\n"
  },
  {
    "path": "docs-i18n/zh-CN/search.md",
    "content": "# `atuin search`\n\n```\natuin search <query>\n```\n\nAtuin 搜索还支持带有 `*` 或 `%` 字符的通配符。 默认情况下，会执行前缀搜索（即，所有查询都会自动附加通配符）。\n\n| 参数               | 描述                                                  |\n| ------------------ | ----------------------------------------------------- |\n| `--cwd/-c`         | 列出历史记录的目录（默认：所有目录）                  |\n| `--exclude-cwd`    | 不包括在此目录中运行的命令（默认值：none）            |\n| `--exit/-e`        | 按退出代码过滤（默认：none）                          |\n| `--exclude-exit`   | 不包括以该值退出的命令（默认值：none）                |\n| `--before`         | 仅包括在此时间之前运行的命令（默认值：none）          |\n| `--after`          | 仅包含在此时间之后运行的命令（默认值：none）          |\n| `--interactive/-i` | 打开交互式搜索 UI（默认值：false）                    |\n| `--human`          | 对时间戳和持续时间使用人类可读的格式（默认值：false） |\n\n## 举例\n\n```\n# 打开交互式搜索 TUI\natuin search -i\n\n# 打开预装了查询的交互式搜索 TUI\natuin search -i atuin\n\n# 搜索所有以 cargo 开头且成功退出的命令。\natuin search --exit 0 cargo\n\n# 从当前目录中搜索所有在2021年4月1日之前运行且失败的命令。\natuin search --exclude-exit 0 --before 01/04/2021 --cwd .\n\n# 搜索所有以 cargo 开头，成功退出且是在昨天下午3点之后运行的命令。\natuin search --exit 0 --after \"yesterday 3pm\" cargo\n```\n"
  },
  {
    "path": "docs-i18n/zh-CN/server.md",
    "content": "# `atuin server`\n\nAtuin 允许您运行自己的同步服务器，以防您不想使用我(ellie)托管的服务器 :)\n\n目前只有一个子命令，`atuin server start`，它将启动 Atuin http 同步服务器。\n\n```\nUSAGE:\n    atuin server start [OPTIONS]\n\nFLAGS:\n        --help       Prints help information\n    -V, --version    Prints version information\n\nOPTIONS:\n    -h, --host <host>\n    -p, --port <port>\n```\n\n## 配置\n\n服务器的配置与客户端的配置是分开的，即使它们是相同的二进制文件。服务器配置可以在 `~/.config/atuin/server.toml` 找到。\n\n它看起来像这样:\n\n```toml\nhost = \"0.0.0.0\"\nport = 8888\nopen_registration = true\ndb_uri=\"postgres://user:password@hostname/database\"\n```\n\n另外，配置也可以用环境变量来提供。\n\n```sh\nATUIN_HOST=\"0.0.0.0\"\nATUIN_PORT=8888\nATUIN_OPEN_REGISTRATION=true\nATUIN_DB_URI=\"postgres://user:password@hostname/database\"\n```\n\n### host\n\nAtuin 服务器应该监听的地址\n\n默认为 `127.0.0.1`.\n\n### port\n\nAtuin 服务器应该监听的端口\n\n默认为 `8888`.\n\n### open_registration\n\n如果为 `true` ，atuin 将接受新用户注册。如果您不希望其他人能够使用您的服务器，请在创建自己的账号后将此设置为 `false` \n\n默认为 `false`.\n\n### db_uri\n\n一个有效的 postgres URI, 用户和历史记录数据将被保存到其中。\n\n### path\n\npath 指的是给 server 添加的路由前缀。值为空字符串将不会添加路由前缀。\n\n默认为 `\"\"`\n\n## 容器部署说明\n\n你可以在容器中部署自己的 atuin 服务器：\n\n* 有关 docker 配置的示例，请参考 [docker](docker.md)。\n* 有关 kubernetes 配置的示例，请参考 [k8s](k8s.md)。\n"
  },
  {
    "path": "docs-i18n/zh-CN/shell-completions.md",
    "content": "# `atuin gen-completions`\n\nAtuin 的 [Shell 补全](https://en.wikipedia.org/wiki/Command-line_completion) 可以通过 `gen-completions` 子命令指定输出目录和所需的 shell 来生成。\n\n```\n$ atuin gen-completions --shell bash --out-dir $HOME\n\nShell completion for BASH is generated in \"/home/user\"\n```\n\n`--shell` 参数的可能值如下：\n\n- `bash`\n- `fish`\n- `zsh`\n- `powershell`\n- `elvish`\n\n此外, 请参阅 [支持的 Shells](./README.md#支持的-Shells).\n"
  },
  {
    "path": "docs-i18n/zh-CN/stats.md",
    "content": "# `atuin stats`\n\nAtuin 还可以根据你的历史记录进行计算统计数据 - 目前这只是一个小的基本功能，但更多功能即将推出\n\n```\n$ atuin stats day last friday\n\n+---------------------+------------+\n| Statistic           | Value      |\n+---------------------+------------+\n| Most used command   | git status |\n+---------------------+------------+\n| Commands ran        |        450 |\n+---------------------+------------+\n| Unique commands ran |        213 |\n+---------------------+------------+\n\n$ atuin stats day 01/01/21 # 也接受绝对日期\n```\n\n它还可以计算所有已知历史记录的统计数据。\n\n```\n$ atuin stats all\n\n+---------------------+-------+\n| Statistic           | Value |\n+---------------------+-------+\n| Most used command   |    ls |\n+---------------------+-------+\n| Commands ran        |  8190 |\n+---------------------+-------+\n| Unique commands ran |  2996 |\n+---------------------+-------+\n```\n"
  },
  {
    "path": "docs-i18n/zh-CN/sync.md",
    "content": "# `atuin sync`\n\nAtuin 可以将您的历史记录备份到服务器，并使用它来确保多台机器具有相同的 shell 历史记录。 这都是端到端加密的，因此服务器操作员_永远_看不到您的数据！\n\n任何人都可以托管一个服务器（尝试 `atuin server start`，更多文档将在后面介绍），但我(ellie)在 https://api.atuin.sh 上托管了一个。这是默认的服务器地址，可以在 [配置](config.md) 中更改。 同样，我_不能_看到您的数据，也不想。\n\n## 同步频率\n\n除非另有配置，否则同步将自动执行。同步的频率可在 [配置](config.md) 中配置。\n\n## 同步\n\n你可以通过 `atuin sync` 来手动触发同步\n\n## 注册\n\n注册一个同步账号\n\n```\natuin register -u <USERNAME> -e <EMAIL> -p <PASSWORD>\n```\n\n用户名（USERNAME）必须是唯一的，电子邮件（EMAIL）仅用于重要通知（安全漏洞、服务更改等）\n\n注册后，意味着你也已经登录了 :) 同步应该从这里自动发生！\n\n## 密钥\n\n由于你的数据是加密的， Atuin 将为你生成一个密钥。它被存储在 Atuin 的数据目录里（ Linux 上为 `~/.local/share/atuin`）\n\n你也可以通过以下方式获得它\n\n```\natuin key\n```\n\n千万不要跟任何人分享这个！\n\n## 登录\n\n如果你想登录到一个新的机器上，你需要你的加密密钥（`atuin key`）。\n\n```\natuin login -u <USERNAME> -p <PASSWORD> -k <KEY>\n```\n\n## 登出\n\n```\natuin logout\n```\n"
  },
  {
    "path": "flake.nix",
    "content": "{\n  inputs = {\n    nixpkgs.url = \"github:NixOS/nixpkgs/nixpkgs-unstable\";\n    flake-utils.url = \"github:numtide/flake-utils\";\n    flake-compat = {\n      url = \"github:edolstra/flake-compat\";\n      flake = false;\n    };\n    fenix = {\n      url = \"github:nix-community/fenix\";\n      inputs.nixpkgs.follows = \"nixpkgs\";\n    };\n  };\n  outputs =\n    { self\n    , nixpkgs\n    , flake-utils\n    , fenix\n    , ...\n    }:\n    flake-utils.lib.eachDefaultSystem\n      (system:\n      let\n        pkgs = nixpkgs.outputs.legacyPackages.${system};\n      in\n      {\n        packages.atuin = pkgs.callPackage ./atuin.nix {\n          rustPlatform =\n            let\n              toolchain =\n                fenix.packages.${system}.fromToolchainFile\n                  {\n                    file = ./rust-toolchain.toml;\n                    sha256 = \"sha256-qqF33vNuAdU5vua96VKVIwuc43j4EFeEXbjQ6+l4mO4=\";\n                  };\n            in\n            pkgs.makeRustPlatform {\n              cargo = toolchain;\n              rustc = toolchain;\n            };\n        };\n        packages.default = self.outputs.packages.${system}.atuin;\n\n        devShells.default = self.packages.${system}.default.overrideAttrs (super: {\n          nativeBuildInputs = with pkgs;\n            super.nativeBuildInputs\n            ++ [\n              cargo-edit\n              clippy\n              rustfmt\n            ];\n          RUST_SRC_PATH = \"${pkgs.rustPlatform.rustLibSrc}\";\n\n          shellHook = ''\n            echo >&2 \"Setting development database path\"\n            export ATUIN_DB_PATH=\"/tmp/atuin_dev.db\"\n            export ATUIN_RECORD_STORE_PATH=\"/tmp/atuin_records.db\"\n\n            if [ -e \"''${ATUIN_DB_PATH}\" ]; then\n              echo >&2 \"''${ATUIN_DB_PATH} already exists, you might want to double-check that\"\n            fi\n\n            if [ -e \"''${ATUIN_RECORD_STORE_PATH}\" ]; then\n              echo >&2 \"''${ATUIN_RECORD_STORE_PATH} already exists, you might want to double-check that\"\n            fi\n          '';\n        });\n      })\n    // {\n      overlays.default = final: prev: {\n        inherit (self.packages.${final.stdenv.hostPlatform.system}) atuin;\n      };\n    };\n}\n"
  },
  {
    "path": "install.sh",
    "content": "#! /bin/sh\nset -eu\n\nATUIN_NON_INTERACTIVE=\"no\"\n\nfor arg in \"$@\"; do\n  case \"$arg\" in\n    --non-interactive) ATUIN_NON_INTERACTIVE=\"yes\" ;;\n    *) ;;\n  esac\ndone\n\nif [ \"$ATUIN_NON_INTERACTIVE\" != \"yes\" ]; then\n  if [ -t 0 ] || { true </dev/tty; } 2>/dev/null; then\n    ATUIN_NON_INTERACTIVE=\"no\"\n  else\n    ATUIN_NON_INTERACTIVE=\"yes\"\n  fi\nfi\n\ncat << EOF\n _______  _______  __   __  ___   __    _\n|   _   ||       ||  | |  ||   | |  |  | |\n|  |_|  ||_     _||  | |  ||   | |   |_| |\n|       |  |   |  |  |_|  ||   | |       |\n|       |  |   |  |       ||   | |  _    |\n|   _   |  |   |  |       ||   | | | |   |\n|__| |__|  |___|  |_______||___| |_|  |__|\n\nMagical shell history\n\nAtuin setup\nhttps://github.com/atuinsh/atuin\nhttps://forum.atuin.sh\n\nPlease file an issue or reach out on the forum if you encounter any problems!\n\n===============================================================================\n\nEOF\n\n__atuin_install_binary(){\n  curl --proto '=https' --tlsv1.2 -LsSf https://github.com/atuinsh/atuin/releases/latest/download/atuin-installer.sh | sh\n}\n\nif ! command -v curl > /dev/null; then\n    echo \"curl not installed. Please install curl.\"\n    exit\nelif ! command -v sed > /dev/null; then\n    echo \"sed not installed. Please install sed.\"\n    exit\nfi\n\n\n__atuin_install_binary\n\n# TODO: Check which shell is in use\n# Use of single quotes around $() is intentional here\n# shellcheck disable=SC2016\nif ! grep -q \"atuin init zsh\" \"${ZDOTDIR:-$HOME}/.zshrc\"; then\n  printf '\\neval \"$(atuin init zsh)\"\\n' >> \"${ZDOTDIR:-$HOME}/.zshrc\"\nfi\n\n# Use of single quotes around $() is intentional here\n# shellcheck disable=SC2016\n\nif ! grep -q \"atuin init bash\" ~/.bashrc; then\n  curl --proto '=https' --tlsv1.2 -LsSf https://raw.githubusercontent.com/rcaloras/bash-preexec/master/bash-preexec.sh -o ~/.bash-preexec.sh\n  printf '\\n[[ -f ~/.bash-preexec.sh ]] && source ~/.bash-preexec.sh\\n' >> ~/.bashrc\n  echo 'eval \"$(atuin init bash)\"' >> ~/.bashrc\nfi\n\nif [ -f \"$HOME/.config/fish/config.fish\" ]; then\n  # Check if the line already exists to prevent duplicates\n  if ! grep -q \"atuin init fish\" \"$HOME/.config/fish/config.fish\"; then\n        # Detect BSD or GNU sed\n        if sed --version >/dev/null 2>&1; then\n          # GNU\n          sed -i '/if status is-interactive/,/end/ s/end$/    atuin init fish | source\\\nend/' \"$HOME/.config/fish/config.fish\"\n        else\n          # BSD (macOS)\n          sed -i '' '/if status is-interactive/,/end/ s/end$/    atuin init fish | source\\\nend/' \"$HOME/.config/fish/config.fish\"\n        fi\n    fi\nfi\n\nATUIN_BIN=\"$HOME/.atuin/bin/atuin\"\n\necho \"\"\necho \"Atuin installed successfully!\"\necho \"\"\n\nif [ \"$ATUIN_NON_INTERACTIVE\" != \"yes\" ]; then\n\n  printf \"Would you like to import your existing shell history into Atuin? [Y/n] \"\n  read -r import_answer </dev/tty || import_answer=\"n\"\n  import_answer=\"${import_answer:-y}\"\n\n  case \"$import_answer\" in\n    [yY]*)\n      echo \"\"\n      if ! \"$ATUIN_BIN\" import auto; then\n        echo \"\"\n        echo \"History import failed. You can retry later with 'atuin import auto'.\"\n      fi\n      echo \"\"\n      ;;\n    *)\n      echo \"Skipping history import. You can always run 'atuin import auto' later.\"\n      echo \"\"\n      ;;\n  esac\n\n  cat << EOF\nSync your history across all your machines with Atuin Cloud:\n\n  - End-to-end encrypted — only you can read your data\n  - Access your history from any device\n  - Never lose your history, even if you wipe a machine\n\nEOF\n\n  printf \"Sign up for a sync account? [Y/n] \"\n  read -r sync_answer </dev/tty || sync_answer=\"n\"\n  sync_answer=\"${sync_answer:-y}\"\n\n  case \"$sync_answer\" in\n    [yY]*)\n      echo \"\"\n      if ! \"$ATUIN_BIN\" register </dev/tty; then\n        echo \"\"\n        echo \"Registration did not complete. You can run 'atuin register' any time to try again.\"\n      fi\n      ;;\n    *)\n      echo \"\"\n      printf \"Already have an account? Log in with 'atuin login'.\\n\"\n      echo \"You can also run 'atuin register' any time to create one.\"\n      ;;\n  esac\n\nelse\n  echo \"Non-interactive environment detected — skipping setup prompts.\"\n  echo \"You can run the following commands manually after installation:\"\n  echo \"\"\n  echo \"  atuin import auto       Import your existing shell history\"\n  echo \"  atuin register          Sign up for a sync account\"\n  echo \"  atuin login             Log in to an existing sync account\"\nfi\n\nif [ \"$ATUIN_NON_INTERACTIVE\" != \"yes\" ]; then\n  \"$ATUIN_BIN\" setup </dev/tty\nfi\n\ncat << EOF\n\n _______  __   __  _______  __    _  ___   _    __   __  _______  __   __\n|       ||  | |  ||   _   ||  |  | ||   | | |  |  | |  ||       ||  | |  |\n|_     _||  |_|  ||  |_|  ||   |_| ||   |_| |  |  |_|  ||   _   ||  | |  |\n  |   |  |       ||       ||       ||      _|  |       ||  | |  ||  |_|  |\n  |   |  |       ||       ||  _    ||     |_   |_     _||  |_|  ||       |\n  |   |  |   _   ||   _   || | |   ||    _  |    |   |  |       ||       |\n  |___|  |__| |__||__| |__||_|  |__||___| |_|    |___|  |_______||_______|\n\nThanks for installing Atuin! I really hope you like it.\n\nIf you have any issues, please open an issue on GitHub or visit our forum (https://forum.atuin.sh)!\n\nIf you love Atuin, please give us a star on GitHub! It really helps ⭐️ https://github.com/atuinsh/atuin\n\n===============================================================================\n\n ⚠️  Please restart your shell or open a new terminal for Atuin to take effect!\n\n===============================================================================\nEOF\n"
  },
  {
    "path": "k8s/atuin.yaml",
    "content": "---\napiVersion: apps/v1\nkind: Deployment\nmetadata:\n  name: atuin\nspec:\n  replicas: 1\n  selector:\n    matchLabels:\n      io.kompose.service: atuin\n  template:\n    metadata:\n      labels:\n        io.kompose.service: atuin\n    spec:\n      containers:\n        - args:\n            - start\n          env:\n            - name: ATUIN_DB_URI\n              valueFrom:\n                secretKeyRef:\n                  name: atuin-secrets\n                  key: ATUIN_DB_URI\n                  optional: false\n            - name: ATUIN_HOST\n              value: 0.0.0.0\n            - name: ATUIN_PORT\n              value: \"8888\"\n            - name: ATUIN_OPEN_REGISTRATION\n              value: \"true\"\n          image: ghcr.io/atuinsh/atuin:latest\n          name: atuin\n          ports:\n            - containerPort: &port 8888\n          resources:\n            limits:\n              cpu: 250m\n              memory: 1Gi\n            requests:\n              cpu: 250m\n              memory: 1Gi\n          startupProbe:\n            httpGet:\n              path: /healthz\n              port: *port\n            failureThreshold: 30\n            periodSeconds: 10\n          livenessProbe:\n            httpGet:\n              path: /healthz\n              port: *port\n            initialDelaySeconds: 3\n            periodSeconds: 3\n          readinessProbe:\n            tcpSocket:\n              port: *port\n            initialDelaySeconds: 15\n            periodSeconds: 10\n          volumeMounts:\n            - mountPath: /config\n              name: atuin-claim0\n        - name: postgresql\n          image: postgres:14\n          ports:\n            - containerPort: 5432\n          env:\n            - name: POSTGRES_DB\n              value: atuin\n            - name: POSTGRES_PASSWORD\n              valueFrom:\n                secretKeyRef:\n                  name: atuin-secrets\n                  key: ATUIN_DB_PASSWORD\n                  optional: false\n            - name: POSTGRES_USER\n              valueFrom:\n                secretKeyRef:\n                  name: atuin-secrets\n                  key: ATUIN_DB_USERNAME\n                  optional: false\n          resources:\n            limits:\n              cpu: 250m\n              memory: 1Gi\n            requests:\n              cpu: 250m\n              memory: 1Gi\n          volumeMounts:\n            - mountPath: /var/lib/postgresql/data/\n              name: database\n      volumes:\n        - name: database\n          persistentVolumeClaim:\n            claimName: database\n        - name: atuin-claim0\n          persistentVolumeClaim:\n            claimName: atuin-claim0\n---\napiVersion: v1\nkind: Service\nmetadata:\n  labels:\n    io.kompose.service: atuin\n  name: atuin\nspec:\n  type: NodePort\n  ports:\n    - name: \"8888\"\n      port: 8888\n      nodePort: 31929\n  selector:\n    io.kompose.service: atuin\n---\nkind: PersistentVolume\napiVersion: v1\nmetadata:\n  name: database-pv\n  labels:\n    app: database\n    type: local\nspec:\n  storageClassName: manual\n  capacity:\n    storage: 300Mi\n  accessModes:\n    - ReadWriteOnce\n  hostPath:\n    path: \"/Users/firstname.lastname/.kube/database\"\n---\napiVersion: v1\nkind: PersistentVolumeClaim\nmetadata:\n  labels:\n    io.kompose.service: database\n  name: database\nspec:\n  storageClassName: manual\n  accessModes:\n    - ReadWriteOnce\n  resources:\n    requests:\n      storage: 300Mi\n---\napiVersion: v1\nkind: PersistentVolumeClaim\nmetadata:\n  labels:\n    io.kompose.service: atuin-claim0\n  name: atuin-claim0\nspec:\n  accessModes:\n    - ReadWriteOnce\n  resources:\n    requests:\n      storage: 10Mi\n"
  },
  {
    "path": "k8s/namespaces.yaml",
    "content": "apiVersion: v1\nkind: Namespace\nmetadata:\n  name: atuin-namespace\n  labels:\n    name: atuin\n"
  },
  {
    "path": "k8s/secrets.yaml",
    "content": "apiVersion: v1\nkind: Secret\nmetadata:\n  name: atuin-secrets\ntype: Opaque\nstringData:\n  ATUIN_DB_USERNAME: atuin\n  ATUIN_DB_PASSWORD: seriously-insecure\n  ATUIN_HOST: \"127.0.0.1\"\n  ATUIN_PORT: \"8888\"\n  ATUIN_OPEN_REGISTRATION: \"true\"\n  ATUIN_DB_URI: \"postgres://atuin:seriously-insecure@localhost/atuin\"\nimmutable: true\n"
  },
  {
    "path": "rust-toolchain.toml",
    "content": "[toolchain]\nchannel = \"1.94.0\"\n"
  },
  {
    "path": "scripts/span-table.ts",
    "content": "#!/usr/bin/env bun\n/**\n * Analyze span timing JSON logs generated with ATUIN_SPAN\n *\n * Usage: bun scripts/span-table.ts <file.json> [options]\n *   --filter <pattern>  Only show spans matching pattern (regex)\n *   --sort <field>      Sort by: calls, avg, total, p99 (default: total)\n *   --top <n>           Show top N spans (default: 20)\n *   --detail <span>     Show individual calls for a specific span\n *   --all               Include internal/library spans\n */\n\nimport { readFileSync } from \"fs\";\n\ninterface SpanEvent {\n  timestamp: string;\n  level: string;\n  fields: {\n    message: string;\n    \"time.busy\"?: string;\n    \"time.idle\"?: string;\n  };\n  target: string;\n  span?: {\n    name: string;\n    [key: string]: unknown;\n  };\n  spans?: Array<{ name: string; [key: string]: unknown }>;\n}\n\ninterface SpanStats {\n  name: string;\n  calls: number;\n  busyTimes: number[]; // in microseconds\n  idleTimes: number[];\n  parentCounts: Map<string, number>; // parent span name -> count\n}\n\n// Parse duration strings like \"1.23ms\", \"456µs\", \"789ns\" to microseconds\nfunction parseDuration(duration: string): number {\n  const match = duration.match(/^([\\d.]+)(ns|µs|us|ms|s)$/);\n  if (!match) return 0;\n\n  const value = parseFloat(match[1]);\n  const unit = match[2];\n\n  switch (unit) {\n    case \"ns\":\n      return value / 1000;\n    case \"µs\":\n    case \"us\":\n      return value;\n    case \"ms\":\n      return value * 1000;\n    case \"s\":\n      return value * 1_000_000;\n    default:\n      return 0;\n  }\n}\n\n// Format microseconds for display\nfunction formatDuration(us: number): string {\n  if (us < 1) {\n    return `${(us * 1000).toFixed(0)}ns`;\n  } else if (us < 1000) {\n    return `${us.toFixed(2)}µs`;\n  } else if (us < 1_000_000) {\n    return `${(us / 1000).toFixed(2)}ms`;\n  } else {\n    return `${(us / 1_000_000).toFixed(2)}s`;\n  }\n}\n\nfunction percentile(arr: number[], p: number): number {\n  if (arr.length === 0) return 0;\n  const sorted = [...arr].sort((a, b) => a - b);\n  const idx = Math.floor(sorted.length * p);\n  return sorted[Math.min(idx, sorted.length - 1)];\n}\n\nfunction parseJsonLines(content: string): SpanEvent[] {\n  const events: SpanEvent[] = [];\n  for (const line of content.trim().split(\"\\n\")) {\n    if (!line.trim()) continue;\n    try {\n      events.push(JSON.parse(line));\n    } catch {\n      // Skip malformed lines\n    }\n  }\n  return events;\n}\n\nfunction main() {\n  const args = process.argv.slice(2);\n\n  // Parse arguments\n  let filterPattern: RegExp | null = null;\n  let sortField = \"total\";\n  let topN = 20;\n  let detailSpan: string | null = null;\n  let showAll = false;\n  const files: string[] = [];\n\n  for (let i = 0; i < args.length; i++) {\n    if (args[i] === \"--filter\" && args[i + 1]) {\n      filterPattern = new RegExp(args[++i]);\n    } else if (args[i] === \"--sort\" && args[i + 1]) {\n      sortField = args[++i];\n    } else if (args[i] === \"--top\" && args[i + 1]) {\n      topN = parseInt(args[++i], 10);\n    } else if (args[i] === \"--detail\" && args[i + 1]) {\n      detailSpan = args[++i];\n    } else if (args[i] === \"--all\") {\n      showAll = true;\n    } else if (!args[i].startsWith(\"-\")) {\n      files.push(args[i]);\n    }\n  }\n\n  if (files.length === 0) {\n    console.error(\"Usage: bun scripts/span-table.ts <file.json> [options]\");\n    console.error(\"  --filter <pattern>  Only show spans matching pattern (regex)\");\n    console.error(\"  --sort <field>      Sort by: calls, avg, total, p99 (default: total)\");\n    console.error(\"  --top <n>           Show top N spans (default: 20)\");\n    console.error(\"  --detail <span>     Show individual calls for a specific span\");\n    console.error(\"  --all               Include internal/library spans\");\n    process.exit(1);\n  }\n\n  // Parse all files\n  const allEvents: SpanEvent[] = [];\n  for (const file of files) {\n    const content = readFileSync(file, \"utf-8\");\n    for (const event of parseJsonLines(content)) {\n      allEvents.push(event);\n    }\n  }\n\n  // Filter to close events and aggregate by span name\n  const spans = new Map<string, SpanStats>();\n\n  for (const event of allEvents) {\n    if (event.fields?.message !== \"close\") continue;\n    if (!event.span?.name) continue;\n    if (!event.fields[\"time.busy\"]) continue;\n\n    const name = event.span.name;\n\n    // Apply filter if specified\n    if (filterPattern && !filterPattern.test(name)) continue;\n\n    // Skip noisy internal spans unless explicitly requested\n    if (\n      !showAll &&\n      !filterPattern &&\n      !detailSpan &&\n      (name.startsWith(\"FramedRead::\") ||\n        name.startsWith(\"FramedWrite::\") ||\n        name.startsWith(\"Prioritize::\") ||\n        name === \"poll\" ||\n        name === \"poll_ready\" ||\n        name === \"Connection\" ||\n        name.startsWith(\"assign_\") ||\n        name.startsWith(\"reserve_\") ||\n        name.startsWith(\"try_\") ||\n        name.startsWith(\"send_\") ||\n        name.startsWith(\"pop_\"))\n    ) {\n      continue;\n    }\n\n    if (!spans.has(name)) {\n      spans.set(name, { name, calls: 0, busyTimes: [], idleTimes: [], parentCounts: new Map() });\n    }\n\n    const stats = spans.get(name)!;\n    stats.calls++;\n    stats.busyTimes.push(parseDuration(event.fields[\"time.busy\"]));\n    if (event.fields[\"time.idle\"]) {\n      stats.idleTimes.push(parseDuration(event.fields[\"time.idle\"]));\n    }\n\n    // Track parent relationship (immediate parent is the last element in spans array)\n    const parents = event.spans || [];\n    const parentName = parents.length > 0 ? parents[parents.length - 1].name : \"__root__\";\n    stats.parentCounts.set(parentName, (stats.parentCounts.get(parentName) || 0) + 1);\n  }\n\n  if (spans.size === 0) {\n    console.error(\"No matching span close events found\");\n    process.exit(1);\n  }\n\n  // Detail mode: show individual calls for a specific span\n  if (detailSpan) {\n    const detailEvents: Array<{\n      timestamp: string;\n      busy: number;\n      idle: number;\n      fields: Record<string, unknown>;\n      parents: string[];\n    }> = [];\n\n    for (const event of allEvents) {\n      if (event.fields?.message !== \"close\") continue;\n      if (event.span?.name !== detailSpan) continue;\n      if (!event.fields[\"time.busy\"]) continue;\n\n      // Extract span fields (excluding name)\n      const fields: Record<string, unknown> = {};\n      if (event.span) {\n        for (const [k, v] of Object.entries(event.span)) {\n          if (k !== \"name\") fields[k] = v;\n        }\n      }\n\n      // Get parent span names\n      const parents = (event.spans || []).map((s) => s.name);\n\n      detailEvents.push({\n        timestamp: event.timestamp,\n        busy: parseDuration(event.fields[\"time.busy\"]),\n        idle: event.fields[\"time.idle\"] ? parseDuration(event.fields[\"time.idle\"]) : 0,\n        fields,\n        parents,\n      });\n    }\n\n    if (detailEvents.length === 0) {\n      console.error(`No events found for span \"${detailSpan}\"`);\n      process.exit(1);\n    }\n\n    console.log(\"\");\n    console.log(`Individual calls for: ${detailSpan}`);\n    console.log(\"-\".repeat(110));\n    console.log(\n      \"#\".padStart(4) +\n        \"Wall\".padStart(12) +\n        \"Busy\".padStart(12) +\n        \"Idle\".padStart(12) +\n        \"  Fields\"\n    );\n    console.log(\"-\".repeat(110));\n\n    detailEvents.forEach((e, i) => {\n      const fieldsStr = Object.keys(e.fields).length > 0\n        ? JSON.stringify(e.fields)\n        : \"\";\n\n      console.log(\n        (i + 1).toString().padStart(4) +\n          formatDuration(e.busy + e.idle).padStart(12) +\n          formatDuration(e.busy).padStart(12) +\n          formatDuration(e.idle).padStart(12) +\n          \"  \" +\n          fieldsStr\n      );\n    });\n\n    // Summary stats\n    const busyTimes = detailEvents.map((e) => e.busy);\n    const wallTimes = detailEvents.map((e) => e.busy + e.idle);\n    console.log(\"\");\n    console.log(\n      `Summary: ${detailEvents.length} calls\\n` +\n        `  Wall: avg=${formatDuration(wallTimes.reduce((a, b) => a + b, 0) / wallTimes.length)}, ` +\n        `min=${formatDuration(Math.min(...wallTimes))}, ` +\n        `max=${formatDuration(Math.max(...wallTimes))}, ` +\n        `p50=${formatDuration(percentile(wallTimes, 0.5))}, ` +\n        `p99=${formatDuration(percentile(wallTimes, 0.99))}\\n` +\n        `  Busy: avg=${formatDuration(busyTimes.reduce((a, b) => a + b, 0) / busyTimes.length)}, ` +\n        `min=${formatDuration(Math.min(...busyTimes))}, ` +\n        `max=${formatDuration(Math.max(...busyTimes))}, ` +\n        `p50=${formatDuration(percentile(busyTimes, 0.5))}, ` +\n        `p99=${formatDuration(percentile(busyTimes, 0.99))}`\n    );\n    return;\n  }\n\n  // Calculate stats\n  const results = [...spans.values()].map((s) => {\n    // Calculate wall times (busy + idle) for each call\n    const wallTimes = s.busyTimes.map((busy, i) => busy + (s.idleTimes[i] || 0));\n\n    // Find most common parent\n    let mostCommonParent = \"__root__\";\n    let maxCount = 0;\n    for (const [parent, count] of s.parentCounts) {\n      if (count > maxCount) {\n        maxCount = count;\n        mostCommonParent = parent;\n      }\n    }\n\n    return {\n      name: s.name,\n      calls: s.calls,\n      total: s.busyTimes.reduce((a, b) => a + b, 0),\n      avg: s.busyTimes.reduce((a, b) => a + b, 0) / s.calls,\n      min: Math.min(...s.busyTimes),\n      max: Math.max(...s.busyTimes),\n      p50: percentile(s.busyTimes, 0.5),\n      p99: percentile(s.busyTimes, 0.99),\n      avgWall: wallTimes.reduce((a, b) => a + b, 0) / s.calls,\n      p50Wall: percentile(wallTimes, 0.5),\n      p99Wall: percentile(wallTimes, 0.99),\n      parent: mostCommonParent,\n    };\n  });\n\n  // Build tree structure\n  const childrenOf = new Map<string, string[]>();\n  childrenOf.set(\"__root__\", []);\n  for (const r of results) {\n    if (!childrenOf.has(r.name)) {\n      childrenOf.set(r.name, []);\n    }\n    if (!childrenOf.has(r.parent)) {\n      childrenOf.set(r.parent, []);\n    }\n    childrenOf.get(r.parent)!.push(r.name);\n  }\n\n  // Sort children by the specified field\n  const resultMap = new Map(results.map(r => [r.name, r]));\n  const sortChildren = (children: string[]) => {\n    children.sort((a, b) => {\n      const ra = resultMap.get(a);\n      const rb = resultMap.get(b);\n      if (!ra || !rb) return 0;\n      switch (sortField) {\n        case \"calls\":\n          return rb.calls - ra.calls;\n        case \"avg\":\n          return rb.avg - ra.avg;\n        case \"p99\":\n          return rb.p99 - ra.p99;\n        case \"total\":\n        default:\n          return rb.total - ra.total;\n      }\n    });\n  };\n\n  // Traverse tree to build ordered display list with depths\n  const displayResults: Array<{ result: typeof results[0]; depth: number }> = [];\n  const visited = new Set<string>();\n\n  function traverse(name: string, depth: number) {\n    if (visited.has(name)) return;\n    visited.add(name);\n\n    const result = resultMap.get(name);\n    if (result) {\n      displayResults.push({ result, depth });\n    }\n\n    const children = childrenOf.get(name) || [];\n    sortChildren(children);\n    for (const child of children) {\n      traverse(child, depth + 1);\n    }\n  }\n\n  // Start from roots\n  const roots = childrenOf.get(\"__root__\") || [];\n  sortChildren(roots);\n  for (const root of roots) {\n    traverse(root, 0);\n  }\n\n  // Add any orphaned spans (whose parent wasn't in our span list)\n  for (const r of results) {\n    if (!visited.has(r.name)) {\n      displayResults.push({ result: r, depth: 0 });\n    }\n  }\n\n  // Apply topN limit\n  const limitedResults = displayResults.slice(0, topN);\n\n  console.log(\"\");\n  console.log(\n    \"Span Name\".padEnd(40) +\n      \"Calls\".padStart(6) +\n      \"Avg(wall)\".padStart(11) +\n      \"P50(wall)\".padStart(11) +\n      \"P99(wall)\".padStart(11) +\n      \"Avg(busy)\".padStart(11) +\n      \"P50(busy)\".padStart(11) +\n      \"P99(busy)\".padStart(11)\n  );\n  console.log(\"-\".repeat(112));\n\n  for (const { result: r, depth } of limitedResults) {\n    const indent = \"  \".repeat(depth);\n    const maxNameLen = 38 - indent.length;\n    const truncatedName = r.name.length > maxNameLen ? \"...\" + r.name.slice(-(maxNameLen - 3)) : r.name;\n    const displayName = indent + truncatedName;\n\n    console.log(\n      displayName.padEnd(40) +\n        r.calls.toString().padStart(6) +\n        formatDuration(r.avgWall).padStart(11) +\n        formatDuration(r.p50Wall).padStart(11) +\n        formatDuration(r.p99Wall).padStart(11) +\n        formatDuration(r.avg).padStart(11) +\n        formatDuration(r.p50).padStart(11) +\n        formatDuration(r.p99).padStart(11)\n    );\n  }\n\n  console.log(\"\");\n  console.log(`Showing ${limitedResults.length} of ${results.length} spans (sorted by ${sortField})`);\n}\n\nmain();\n"
  },
  {
    "path": "systemd/atuin-server.service",
    "content": "[Unit]\nDescription=Start the Atuin server syncing service\nAfter=network-online.target\nWants=network-online.target systemd-networkd-wait-online.service\n\n[Service]\nExecStart=atuin-server start\nRestart=on-failure\nUser=atuin\nGroup=atuin\n\nEnvironment=ATUIN_CONFIG_DIR=/etc/atuin\nReadWritePaths=/etc/atuin\n\n# Hardening options\nCapabilityBoundingSet=\nAmbientCapabilities=\nNoNewPrivileges=true\nProtectHome=true\nProtectSystem=strict\nProtectKernelTunables=true\nProtectKernelModules=true\nProtectControlGroups=true\nPrivateTmp=true\nPrivateDevices=true\nLockPersonality=true\n\n[Install]\nWantedBy=multi-user.target\n"
  },
  {
    "path": "systemd/atuin-server.sysusers",
    "content": "u atuin - \"Atuin synchronized shell history\"\n"
  }
]